Skip to content

Repository files navigation

opds

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.)

Install

go get github.com/ophymx/opds

Packages

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.

Quick start

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 query

A complete runnable catalog with multiple feeds, subjects, and search lives in examples/bookstore: go run ./examples/bookstore.

The domain model

Both OPDS versions express the same concepts, so you build them once:

  • Feed — a navigation feed (entries link to sub-feeds via AddNav) or an acquisition feed (entries are publications via Add).
  • 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-lending availability / holds / copies.
  • Facet / Group — filtered/sorted views and labelled sections.
  • Pagination via Feed.Page(total, perPage, startIndex) for the counters and Feed.Paged(baseHref, page, hasNext) for the prev/next links (build baseHref with opdshttp.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},
})

Search

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 search link 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"}
}

Page streaming (OPDS-PSE)

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 10

If 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.

Content negotiation

The handler picks the version per request, in priority order:

  1. ?version=1 / ?version=2 (or ?f=atom / ?f=json) query parameter.
  2. The Accept header (application/opds+json → 2.0; atom+xml → 1.2).
  3. The configured default (WithDefaultVersion, defaulting to 1.2 for maximum client compatibility).

URL layout

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)

Using the encoders directly

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 JSON

Conformance

The 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/specs and the Readium Web Publication Manifest schemas they reference. These tests are pure Go (using a vendored copy of the schemas) and run under go 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, so go test ./... still passes everywhere.

To run the 1.2 RELAX NG tests, which fetch Jing into tools/ on first run:

scripts/conformance.sh

Vendored schema provenance (and the one documented upstream-typo fix in opds.rnc) is recorded in the testdata/schema/*/SOURCES.md files.

Status / scope

  • 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.

References

License

MIT © 2026 Jeffrey T. Peckham

About

Go library for building OPDS 1.2 + 2.0 catalog services from a single model.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages