A Go library for building, implementing, and embedding OPDS (Open Publication Distribution System) catalog services — the standard ebook readers and library systems use to browse, search, and acquire publications.
You model your catalog once, with version-neutral types, and the library serializes it to either supported wire format, chosen by content negotiation:
| Version | Format | Status | Clients |
|---|---|---|---|
| OPDS 1.2 | Atom (XML) | stable, universal | Calibre, KOReader, Thorium, FBReader, Foliate, Aldiko, Moon+, … |
| OPDS 2.0 | JSON (Readium Web Publication Manifest) | current draft | Thorium, Readium toolkits, Palace/SimplyE, newer apps |
No runtime dependencies — the library itself uses only the Go standard
library. (A single test-only dependency,
santhosh-tekuri/jsonschema,
powers the OPDS 2.0 conformance tests; it never appears in your builds.)
go get github.com/ophymx/opds| Package | Purpose |
|---|---|
opds |
Version-neutral domain model, constants, and fluent builders. |
opds/opds1 |
Encodes the model to OPDS 1.2 (Atom XML). |
opds/opds2 |
Encodes the model to OPDS 2.0 (JSON). |
opds/opensearch |
Generates OpenSearch description documents (1.x search). |
opds/opdshttp |
Embeddable http.Handler: routing, content negotiation, pagination, search. |
Implement the opds.Source interface (and optionally opds.Searcher), hand it
to opdshttp, and you have a catalog that speaks both OPDS versions.
package main
import (
"context"
"net/http"
"github.com/ophymx/opds"
"github.com/ophymx/opds/opdshttp"
)
type catalog struct{}
func (catalog) Root(_ context.Context, _ opds.FeedRequest) (*opds.Feed, error) {
return opds.NewFeed("urn:cat:root", "My Library").
AddNav("All Books", "/opds/feed/all", opds.MediaTypeAcquisition, opds.RelSortNew), nil
}
func (catalog) Feed(_ context.Context, req opds.FeedRequest) (*opds.Feed, error) {
if req.ID != "all" {
return nil, opds.ErrNotFound
}
book := opds.NewPublication("urn:isbn:9780134190440", "The Go Programming Language").
By("Alan Donovan").By("Brian Kernighan").
In("en").ISBN("9780134190440").About("Computers").
Cover("/covers/gopl.jpg", "image/jpeg").
OpenAccess("/download/gopl.epub", "application/epub+zip")
return opds.NewFeed("urn:cat:all", "All Books").Add(*book), nil
}
func (catalog) Publication(_ context.Context, id string) (*opds.Publication, error) {
return nil, opds.ErrNotFound // serve standalone entry documents if you like
}
func main() {
h := opdshttp.New(catalog{}, opdshttp.WithPrefix("/opds"))
http.Handle("/opds/", h)
http.ListenAndServe(":8080", nil)
}curl localhost:8080/opds/ # OPDS 1.2 (default)
curl -H 'Accept: application/opds+json' localhost:8080/opds/ # OPDS 2.0
curl 'localhost:8080/opds/?version=2' # OPDS 2.0 via queryA complete runnable catalog with multiple feeds, subjects, and search lives in
examples/bookstore: go run ./examples/bookstore.
Both OPDS versions express the same concepts, so you build them once:
Feed— a navigation feed (entries link to sub-feeds viaAddNav) or an acquisition feed (entries are publications viaAdd).Publication— bibliographic metadata, cover images, and acquisition links.Acquisition— how to obtain a publication: open-access, buy, borrow, sample, or subscribe, with prices, indirect acquisition (e.g. LCP → EPUB), and library-lendingavailability/holds/copies.Facet/Group— filtered/sorted views and labelled sections.- Pagination via
Feed.Page(total, perPage, startIndex)for the counters andFeed.Paged(baseHref, page, hasNext)for the prev/next links (buildbaseHrefwithopdshttp.FeedPagePath/opdshttp.SearchPagePath).
Fluent builders keep construction terse:
p := opds.NewPublication("urn:isbn:9781503280786", "Moby-Dick").
By("Herman Melville").In("en").ISBN("9781503280786").
PublishedAt(pubDate).About("Fiction").
Summarize("The voyage of the Pequod.").
Cover("/covers/moby.jpg", "image/jpeg").
Buy("/buy/moby", "application/epub+zip", "USD", 4.99)
// Library lending with availability, a hold queue, and copy counts:
pos := 5
p.Acquire(opds.Acquisition{
Rel: opds.AcquireBorrow,
Href: "/borrow/moby",
Type: "application/epub+zip",
Availability: &opds.Availability{State: opds.StateUnavailable, Until: dueDate},
Holds: &opds.Holds{Total: 12, Position: &pos},
Copies: &opds.Copies{Total: 3, Available: 0},
})If your Source also implements opds.Searcher, the handler automatically:
- serves search results at
{prefix}/search?q=..., - serves an OpenSearch description at
{prefix}/opensearch.xml(for 1.x clients), - advertises a
searchlink in every feed (OpenSearch for 1.2, a templated link for 2.0).
func (catalog) Search(_ context.Context, req opds.SearchRequest) (*opds.Feed, error) {
results := db.Query(req.Terms) // also: req.Author, req.Title, req.Page
f := opds.NewFeed("urn:cat:search", "Results")
for _, b := range results {
f.Add(toPublication(b))
}
return f, nil
}
func (catalog) SearchDescription() opds.SearchDescription {
return opds.SearchDescription{ShortName: "My Library", Description: "Search the catalog"}
}For comics and manga, the OPDS Page Streaming
Extension lets clients
such as KOReader fetch one page image at a time instead of downloading a whole
CBZ. Advertise a stream on a publication with Stream (and optionally
LastRead for server-side resume, PSE 1.2):
comic := opds.NewPublication("urn:comic:vol1", "Vol. 1").
OpenAccess("/dl/vol1.cbz", "application/vnd.comicbook+zip").
Stream(h.PageStreamURL("vol1"), "image/jpeg", 35). // 35 pages
LastRead(10, lastReadAt) // resume at page 10If your Source also implements opds.PageSource, the handler serves the
page images behind the template that PageStreamURL / PageStreamPath
builds ({prefix}/page/{id}?page={pageNumber}&width={maxWidth}):
func (catalog) Page(_ context.Context, req opds.PageRequest) (*opds.PageImage, error) {
img, err := openPage(req.ID, req.Number) // req.Number is zero-based
if err != nil {
return nil, opds.ErrNotFound
}
return &opds.PageImage{Type: "image/jpeg", Content: img}, nil
}req.MaxWidth carries the client's desired maximum width; implementations may
ignore it and serve full-size images. PSE is an OPDS 1.x extension with no
2.0 mapping: the opds2 encoder omits it and 2.0 clients fall back to the
acquisition links.
The handler picks the version per request, in priority order:
?version=1/?version=2(or?f=atom/?f=json) query parameter.- The
Acceptheader (application/opds+json→ 2.0;atom+xml→ 1.2). - The configured default (
WithDefaultVersion, defaulting to 1.2 for maximum client compatibility).
opdshttp.Handler serves this layout relative to its mount prefix. Use the
FeedPath, PublicationPath, and SearchPath helpers (or the handler's
FeedURL etc. methods) to build matching hrefs in your Source:
{prefix}/ root feed
{prefix}/feed/{id} a feed by id
{prefix}/publication/{id} a single publication document
{prefix}/search search results
{prefix}/opensearch.xml OpenSearch description
{prefix}/page/{id} a page image (OPDS-PSE, if the Source is a PageSource)
You don't have to use opdshttp. The encoders turn a *opds.Feed into bytes,
so they drop into any framework or static-generation pipeline:
xmlBytes, _ := opds1.Marshal(feed) // OPDS 1.2 Atom
jsonBytes, _ := opds2.Marshal(feed) // OPDS 2.0 JSONThe encoders are tested against the official OPDS schemas, not just hand-written expectations:
- OPDS 2.0 output is validated against the official JSON Schemas from
opds-community/specsand the Readium Web Publication Manifest schemas they reference. These tests are pure Go (using a vendored copy of the schemas) and run undergo test ./.... - OPDS 1.2 output is validated against the official RELAX NG schema
(
opds.rnc) with Jing — the reference validator behind the official OPDS validator. These tests need a JRE and skip automatically when Java/Jing are absent, sogo test ./...still passes everywhere.
To run the 1.2 RELAX NG tests, which fetch Jing into tools/ on first run:
scripts/conformance.shVendored schema provenance (and the one documented upstream-typo fix in
opds.rnc) is recorded in the testdata/schema/*/SOURCES.md files.
- OPDS 1.2 and 2.0 feeds, navigation and acquisition — validated against the official schemas (see Conformance).
- Acquisition model including prices, nested indirect acquisition, and library lending (availability/holds/copies).
- Facets, groups, pagination, OpenSearch.
- Page streaming for comics/manga via
OPDS-PSE 1.2
(
pse:count,pse:lastRead,pse:lastReadDate) — 1.x feeds only, like the extension itself. The stream link's templated href ({pageNumber}) necessarily goes beyond Atom's strict URI datatype; the conformance tests document that this is the extension's only deviation.
A note on library lending in 1.2: opds:availability/holds/copies are
standard in OPDS 2.0 but are not part of the official OPDS 1.2 RELAX NG schema
— they are a de-facto 1.x extension (Library Simplified/Palace). The library
emits them in both versions because real library clients rely on them; just be
aware that a 1.2 feed using them intentionally goes beyond the core 1.2 schema.
Not yet included: the OPDS Authentication document flow (the model leaves room for it). Contributions welcome.
- OPDS 1.2 specification
- OPDS 2.0 specification
- OPDS-PSE specification
- Readium Web Publication Manifest
- OpenSearch 1.1
MIT © 2026 Jeffrey T. Peckham