A single-binary Go server that combines multiple OPDS 1.2 catalogs into one unified feed. Point your e-reader (KOReader, etc.) at a single URL and browse all your book sources in one place.
- Unified catalog — each upstream feed appears as a top-level entry, with its full structure preserved underneath
- Download proxying — all acquisitions (book downloads, cover images) are proxied through the aggregator
- Basic Auth — protect the aggregator with a username/password; per-source upstream credentials supported
- Periodic polling — configurable automatic refresh of upstream feeds, plus a manual refresh endpoint
- Search — fan-out proxy search across upstream OpenSearch endpoints, merged into a single result feed
- On-demand fetching — uncached sub-feeds are fetched transparently when a client navigates to them
- Server-side pagination — large feeds are automatically paginated to prevent hangs and reduce memory usage
- KOReader compatible — tested with KOReader; serves OPDS 1.2 Atom XML with proper facet passthrough
Requires Go 1.21+.
go build -o opds-aggregator .This produces a single static binary.
Copy the example config and edit it:
cp config.example.yaml config.yamlThe server looks for config in this order:
- Path passed via
--configflag ./config.yamlor./config.yml~/.config/opds-aggregator/config.yaml$XDG_CONFIG_HOME/opds-aggregator/config.yaml
server:
addr: ":8080"
title: "My Books"
auth:
username: "reader"
password: "changeme"
default_max_entries: 100
polling:
interval: "6h"
feeds:
- name: "Project Gutenberg"
url: "https://m.gutenberg.org/ebooks.opds/"
poll_depth: 0
max_entries: 50 # paginate large catalog
max_paginate: 1 # only fetch one upstream page at a time
- name: "Standard Ebooks"
url: "https://standardebooks.org/feeds/opds"
poll_depth: 1
- name: "My Calibre Library"
url: "https://mycalibre.example.com/opds"
auth:
username: "user"
password: "secret"
poll_depth: 2| Field | Description | Default |
|---|---|---|
server.addr |
Listen address | :8080 |
server.title |
Root catalog title | OPDS Aggregator |
server.auth |
Basic Auth credentials for the aggregator (omit to disable) | — |
server.default_max_entries |
Default max entries per page for server-side pagination (0 = unlimited) | 0 |
polling.interval |
How often to re-crawl upstream feeds (Go duration) | 6h |
feeds[].name |
Display name for the source | required |
feeds[].url |
OPDS catalog root URL | required |
feeds[].auth |
Basic Auth credentials for this upstream | — |
feeds[].poll_depth |
How many levels of navigation to pre-crawl (0 = root only) | 0 |
feeds[].max_entries |
Max entries per page for this feed (0 = use server default) | 0 |
feeds[].max_paginate |
Max upstream pages to follow when fetching (0 = all) | 0 |
poll_depth tip: Use 0 for large catalogs like Gutenberg (sub-feeds are fetched on demand). Use 1–2 for small personal libraries to pre-populate the cache.
Pagination tip: For large catalogs (e.g., Gutenberg with 70k+ entries), set max_entries: 50 and max_paginate: 1 to prevent hangs. The aggregator will serve paginated responses with rel="next" links that clients can follow.
All settings can be configured via environment variables, which override YAML values. If no config file is found, the application runs entirely from environment variables.
| Variable | Description |
|---|---|
OPDS_SERVER_ADDR |
Listen address (e.g., :8080) |
OPDS_SERVER_TITLE |
Root catalog title |
OPDS_SERVER_DEFAULT_MAX_ENTRIES |
Default max entries per page (0 = unlimited) |
OPDS_AUTH_USERNAME |
Basic Auth username |
OPDS_AUTH_PASSWORD |
Basic Auth password |
OPDS_POLLING_INTERVAL |
Refresh interval (Go duration, e.g., 6h) |
OPDS_DEBUG |
Set to true for debug logging |
Feeds are configured with indexed variables:
| Variable | Description |
|---|---|
OPDS_FEED_0_NAME |
First feed's display name |
OPDS_FEED_0_URL |
First feed's OPDS URL |
OPDS_FEED_0_POLL_DEPTH |
First feed's crawl depth |
OPDS_FEED_0_MAX_ENTRIES |
First feed's max entries per page |
OPDS_FEED_0_MAX_PAGINATE |
First feed's max upstream pages to follow |
OPDS_FEED_0_AUTH_USERNAME |
First feed's upstream auth username |
OPDS_FEED_0_AUTH_PASSWORD |
First feed's upstream auth password |
Increment the index for additional feeds (OPDS_FEED_1_*, OPDS_FEED_2_*, etc.). If any OPDS_FEED_* variables are set, they replace all YAML-defined feeds.
# With default config location
./opds-aggregator
# With explicit config
./opds-aggregator --config /path/to/config.yamlThe server performs an initial crawl of all feeds on startup, then polls at the configured interval.
Container images are published to GitHub Container Registry for linux/amd64 and linux/arm64.
# Pull the latest image
docker pull ghcr.io/madeddie/opds-aggregator:latest
# Run with a config file
docker run -d \
-p 8080:8080 \
-v /path/to/config.yaml:/config.yaml:ro \
ghcr.io/madeddie/opds-aggregator --config /config.yaml
# Run with environment variables only
docker run -d \
-p 8080:8080 \
-e OPDS_AUTH_USERNAME=reader \
-e OPDS_AUTH_PASSWORD=secret \
-e OPDS_FEED_0_NAME="Standard Ebooks" \
-e OPDS_FEED_0_URL="https://standardebooks.org/feeds/opds" \
-e OPDS_FEED_0_POLL_DEPTH=1 \
ghcr.io/madeddie/opds-aggregatorservices:
opds-aggregator:
image: ghcr.io/madeddie/opds-aggregator:latest
ports:
- "8080:8080"
environment:
- OPDS_AUTH_USERNAME=reader
- OPDS_AUTH_PASSWORD=changeme
- OPDS_POLLING_INTERVAL=6h
- OPDS_FEED_0_NAME=Standard Ebooks
- OPDS_FEED_0_URL=https://standardebooks.org/feeds/opds
- OPDS_FEED_0_POLL_DEPTH=1
restart: unless-stoppedIn KOReader, go to Search > OPDS catalog and add a new catalog:
- Catalog name: whatever you like
- Catalog URL:
http://your-server:8080/opds
If you configured auth, KOReader will prompt for username and password on first access.
| Method | Path | Description |
|---|---|---|
GET |
/opds |
Catalog root (navigation feed listing all sources) |
GET |
/opds/source/{slug}/... |
Browse a specific source's feeds |
GET |
/opds/download/{slug}?url=... |
Proxied download (books, covers) |
GET |
/opds/search?q=... |
Search across all sources |
GET |
/opds/search/{slug}?q=...&upstream=... |
Search within one source |
POST |
/opds/refresh |
Trigger manual refresh of all feeds |
POST |
/opds/refresh/{slug} |
Trigger manual refresh of one feed |
MIT