Obligatory screenshot for the reddit trolls:

GoPDS is a lightweight OPDS server for EPUB libraries, with a web UI for live metadata and cover editing.
It builds to a single static binary (pure-Go SQLite, no CGO) and ships as a ~21MB container image.
- OPDS catalog serving with large-library navigation:
- Root OPDS navigation feed at
/opds - Author-range browsing (
authors=a,authors=a-d) with pagination - Category/subcategory browsing at
/opds/categories
- Root OPDS navigation feed at
- Public book access:
- OPDS feeds
- JSON list (
/api/books) - Book downloads (
/download/{id})
- Authenticated admin editing:
- Live EPUB metadata edit/write
- Open Library + Google Books compare/apply workflow
- Cover candidate selection and apply, from inside the EPUB or from online sources
- Optional write selected cover into EPUB (
write_to_epub) - Rebuild/rescan controls
- Cover behavior:
- Cache cover writes to
{data dir}/covers/{id}.jpg - When writing to EPUB, also writes sibling
cover.jpgnext to the EPUB file - EPUB cover normalization prefers canonical
cover.jpg
- Cache cover writes to
- Scanner modes:
- Incremental rescan (changed/new books only, based on file mod time)
- Full rebuild (drop DB cache + clear cover cache + full reindex)
All configuration is via environment variables.
| Variable | Default | Purpose |
|---|---|---|
BOOK_PATH |
./books |
Root of the EPUB library. |
DB_PATH |
./data/gopds.db |
SQLite cache location. Parent directories are created automatically. |
ADMIN_USERNAME |
admin |
Admin username. |
ADMIN_PASSWORD |
(none) | Required for authenticated features. If empty, all admin endpoints return 401. |
CATEGORY_SOURCE |
none |
How categories are derived: path, subject, auto, or none. See below. |
ONLINE_COVER_MIN_WIDTH |
300 |
Online cover candidates narrower than this are discarded. |
ONLINE_COVER_MIN_HEIGHT |
420 |
Online cover candidates shorter than this are discarded. |
ENRICH_RATE_MS |
1000 |
Milliseconds between upstream lookups during background enrichment. |
LISTEN_ADDR |
:8880 |
Address the HTTP server binds to. |
The server listens on port 8880 by default (see LISTEN_ADDR).
CATEGORY_SOURCE selects how each book's category and subcategory are determined during a scan:
path— derived from directory layout underBOOK_PATH:- category = first folder under
BOOK_PATH - subcategory = second folder under
BOOK_PATH(optional)
- category = first folder under
subject— derived from the EPUB'sdc:subjectmetadata. Hierarchical subjects (Fiction > Science Fiction,Fiction/Science Fiction) are split into category and subcategory; otherwise the first two subjects are used.auto— trysubjectfirst, fall back topathwhen the EPUB has no usable subjects.none— no categories. The "Browse by Category" entry is omitted from the OPDS root.
Two older boolean toggles are still honored for backward compatibility, but only when
CATEGORY_SOURCE is unset: CATEGORY_FROM_SUBJECT (checked first) and CATEGORY_FROM_PATH.
Both accept 1, true, yes, or on. New deployments should use CATEGORY_SOURCE.
services:
gopds:
image: ghcr.io/ab0oo/gopds:latest
container_name: gopds
user: "1000:1000" # Must own the book library if you want metadata/cover writes
ports:
- "8880:8880"
environment:
- BOOK_PATH=/app/books
- DB_PATH=/app/data/gopds.db
- CATEGORY_SOURCE=subject
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=${GOPDS_ADMIN_PASSWORD:?set this in a .env file}
volumes:
- /path/to/books:/app/books
- /path/to/gopds-data:/app/data
restart: unless-stoppedImportant:
- The data volume must be writable by the container user, and must not live on
tmpfs(e.g. under/var/run), or the index and cover cache are lost on every reboot. A missing-but-creatable directory is fine; an unwritable one fails at startup withunable to open database file. - If you want EPUB metadata/cover writes, the books volume must be writable too.
- If
ADMIN_PASSWORDis empty, admin-protected editing features are unavailable. - Keep real passwords out of committed compose files; use a
.envfile or secrets.
GET /opds- OPDS root navigation feed.
GET /opds?authors=aGET /opds?authors=a-d&page=1&limit=100- Author-range acquisition feeds (paginated).
authors=othercovers non-alphabetic authors.limitdefaults to 100 and is capped at 250.
- Author-range acquisition feeds (paginated).
GET /opds/authors- Same behavior as
/opds; accepts the sameauthorsselector.
- Same behavior as
GET /opds/categoriesGET /opds/categories?category=FictionGET /opds/categories?category=Fiction&subcategory=SciFi&page=1&limit=100- Category/subcategory navigation + acquisition feeds.
GET / serves the OPDS catalog to OPDS clients and the HTML UI to browsers, based on the
Accept header. Append ?opds=1 to force the catalog.
Public:
GET /opdsGET /opds/authorsGET /opds/categoriesGET /api/booksGET /covers/{id}.jpgGET /download/{id}GET /api/openlibrary/search
Auth/session:
GET /api/auth/statusPOST /api/auth/loginPOST /api/auth/logout
Admin-protected:
GET /api/books/{id}/metadata/livePUT /api/books/{id}/metadataGET /api/books/{id}/covers/candidatesGET /api/books/{id}/covers/onlineGET /api/books/{id}/covers/candidates/{key}PUT /api/books/{id}/coverPOST /api/admin/rescanPOST /api/admin/rebuildGET /api/admin/rebuild/statusGET /api/admin/qualityGET /api/admin/quality/queuePOST /api/admin/covers/upgradePOST /api/admin/enrichPOST /api/admin/enrich/stopGET /api/admin/enrich/statusGET /api/admin/enrich/proposals
Sessions are held in memory with a 12-hour TTL and delivered as an HttpOnly cookie.
They do not survive a server restart.
Every scan scores each book 0-100 on metadata completeness (description, cover
size, author form, category, identifier, series) and records the result in a
book_enrichment table kept separate from books, so a full rebuild never
discards human decisions.
Two cleanup layers build on that score:
Deterministic normalization runs on every scan with no network access. It
canonicalizes Last, First author names to First Last, repairs ALL-CAPS
names, and lifts trailing (Series Name Book 3) annotations out of titles into
proper series fields. Anything it cannot decide with certainty -- multi-author
strings, writing as pseudonyms, series text embedded in the author field -- is
flagged for review rather than rewritten on a guess.
Background enrichment (POST /api/admin/enrich) fills gaps from Open
Library and Google Books. It is deliberately conservative:
- Dry run by default. Pass
?apply=trueto write anything. Inspect the result withGET /api/admin/enrich/proposalsfirst. - Gaps only. A field that already has a local value is never overwritten; the EPUB is treated as more authoritative than a remote guess.
- Only exact matches auto-apply. A match needs an agreeing ISBN or a very strong title and author agreement. Weaker matches are queued for review.
- Database only. The automated pass does not modify EPUB files.
- Locked books are skipped, so manual edits are never clobbered.
- Rate-limited to one upstream request per second (
ENRICH_RATE_MSto adjust).
Cover selection picks the best artwork an EPUB actually contains, judged on
shape and resolution rather than filename order. Book covers are portrait and
roughly 2:3, so landscape and square images are rejected outright -- this is what
stops a large publisher logo from being chosen over the real cover. A sibling
cover.jpg next to the EPUB is used only when it beats the embedded artwork,
since those files are often one shared image copied across an author's whole
directory tree.
Cover upgrade (POST /api/admin/covers/upgrade) looks for better artwork
online for books whose covers are missing or below bookstore quality. It is
dry-run by default like enrichment, only replaces a cover when the replacement
is clearly better, and writes to the cover cache rather than to EPUB files.
GET /api/admin/quality reports library-wide health; GET /api/admin/quality/queue
returns the worst-scoring books first. The Metadata Review button in the web
UI (admin only) presents the same queue with per-book Edit, Cover, Resolve and
Lock actions, plus the enrichment and cover-upgrade controls.
- Browser UI is at
/. - Admin login is required to see and use:
Edit MetadataChange CoverRescan/Rebuildcontrols
- OPDS clients can use
/opds(or root with OPDS accept headers).
go mod tidy
go build -o gopds ./cmd/gopds
./gopdsThen open:
- Web UI:
http://localhost:8880/ - OPDS:
http://localhost:8880/opds
nester.go is a standalone helper that reorganizes a flat directory of EPUBs into
Title/Title.epub subfolders, using each book's own metadata to name them (handy for
libraries full of hash-named files):
go run nester.go "/path/to/Some Author"It is not part of the server build. Delete any stale shared cover.jpg in the directory
before rescanning.
GitHub Actions workflow:
.github/workflows/docker-build.yml- Builds the Docker image on push to
main, tags, releases, PRs, and manual dispatch - Publishes to GHCR on
v*tags and published releases:ghcr.io/<owner>/<repo>:latest(default branch)- ref/tag/sha tags
- Uploads a compressed Docker image tarball artifact for each run
- Use a strong
ADMIN_PASSWORD, supplied via environment or secrets rather than a committed file. - Run behind an HTTPS reverse proxy for internet exposure. The session cookie is only
marked
Securewhen the server itself terminates TLS. - Note that
/api/booksand/download/{id}are unauthenticated: anyone who can reach the server can enumerate and download the library. - Outbound cover fetches are restricted to an allowlist of Open Library, Google Books, and Wikimedia hosts.
- Protect
mainwith required signed commits. - Keep GHCR package visibility intentional (public/private).
This project is licensed under the MIT License. See LICENSE.