Files
bookhoard/internal/opds/feed.go
T
john-okeefe 5d22021e8e fix(opds): epubcfi Atom compatibility, path deduplication, auth tokens
- Add <title> and <author><name> elements to Atom feed for compatibility
- Remove doubled /opds/opds/ path prefix in feed URLs
- Include ?token= auth param on all OPDS URLs
- Serve kepub links only for Kobo devices
- Fix URL construction for entries and acquisitions
2026-06-02 19:45:21 -04:00

195 lines
4.3 KiB
Go

package opds
import (
"encoding/xml"
"fmt"
"time"
)
// OPDS 1.2 Feed Structures
type Feed struct {
XMLName xml.Name `xml:"feed"`
Xmlns string `xml:"xmlns,attr"`
OpdsNS string `xml:"xmlns:opds,attr"`
DcNS string `xml:"xmlns:dc,attr"`
ID string `xml:"id"`
Title string `xml:"title"`
Updated string `xml:"updated"`
Links []Link `xml:"link"`
Entries []Entry `xml:"entry"`
}
type Entry struct {
ID string `xml:"id"`
Title string `xml:"title"`
Author *Author `xml:"author,omitempty"`
Updated string `xml:"updated"`
Summary string `xml:"summary,omitempty"`
Links []Link `xml:"link"`
Identifier *Identifier `xml:"dc:identifier,omitempty"`
Metadata []Meta `xml:"meta"`
Categories []Category `xml:"category,omitempty"`
}
type Author struct {
XMLName xml.Name `xml:"author"`
Name string `xml:"name"`
URI string `xml:"uri,omitempty"`
}
type Link struct {
Href string `xml:"href,attr"`
Type string `xml:"type,attr"`
Rel string `xml:"rel,attr,omitempty"`
}
type Identifier struct {
XMLName xml.Name `xml:"dc:identifier"`
ID string `xml:"id,attr"`
Content string `xml:",chardata"`
}
type Meta struct {
Property string `xml:"property,attr"`
Content string `xml:",chardata"`
}
type Category struct {
Scheme string `xml:"scheme,attr"`
Term string `xml:"term,attr"`
}
// NewFeed creates a new OPDS feed
func NewFeed(feedID, title string) *Feed {
now := time.Now().Format(time.RFC3339)
return &Feed{
Xmlns: "http://www.w3.org/2005/Atom",
OpdsNS: "http://opds-spec.org/2010/",
DcNS: "http://purl.org/dc/elements/1.1/",
ID: feedID,
Title: title,
Updated: now,
Links: []Link{},
Entries: []Entry{},
}
}
// AddLink adds a link to the feed
func (f *Feed) AddLink(href, linkType, rel string) {
f.Links = append(f.Links, Link{
Href: href,
Type: linkType,
Rel: rel,
})
}
// AddEntry adds an entry to the feed
func (f *Feed) AddEntry(entry Entry) {
f.Entries = append(f.Entries, entry)
}
// NewEntry creates a new OPDS entry
func NewEntry(id, title, creator, updated string) Entry {
e := Entry{
ID: id,
Title: title,
Updated: updated,
Links: []Link{},
Metadata: []Meta{},
}
if creator != "" {
e.Author = &Author{Name: creator}
}
return e
}
// AddAcquisitionLink adds an acquisition link to the entry
func (e *Entry) AddAcquisitionLink(href, linkType string) {
e.Links = append(e.Links, Link{
Href: href,
Type: linkType,
Rel: "http://opds-spec.org/acquisition/open-access",
})
}
// AddAlternateLink adds an alternate link to the entry
func (e *Entry) AddAlternateLink(href, linkType string) {
e.Links = append(e.Links, Link{
Href: href,
Type: linkType,
Rel: "alternate",
})
}
// AddLink adds a generic link to the entry
func (e *Entry) AddLink(href, linkType, rel string) {
e.Links = append(e.Links, Link{
Href: href,
Type: linkType,
Rel: rel,
})
}
// SetIdentifier sets the canonical identifier
func (e *Entry) SetIdentifier(id string) {
e.Identifier = &Identifier{
ID: "bookhoard",
Content: id,
}
}
// AddMetadata adds metadata to the entry
func (e *Entry) AddMetadata(property, content string) {
e.Metadata = append(e.Metadata, Meta{
Property: property,
Content: content,
})
}
// AddCategory adds a category to the entry
func (e *Entry) AddCategory(scheme, term string) {
e.Categories = append(e.Categories, Category{
Scheme: scheme,
Term: term,
})
}
// SetSummary sets the entry summary
func (e *Entry) SetSummary(summary string) {
e.Summary = summary
}
// GenerateXML generates the OPDS XML
func (f *Feed) GenerateXML() ([]byte, error) {
output, err := xml.MarshalIndent(f, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal OPDS feed: %w", err)
}
return output, nil
}
// GenerateXMLString generates the OPDS XML as a string
func (f *Feed) GenerateXMLString() (string, error) {
output, err := f.GenerateXML()
if err != nil {
return "", err
}
return xml.Header + string(output), nil
}
// NewErrorFeed creates an error feed
func NewErrorFeed(message string) *Feed {
feed := NewFeed(
fmt.Sprintf("urn:uuid:error-%d", time.Now().Unix()),
"Error",
)
feed.AddEntry(Entry{
ID: "urn:uuid:error",
Title: "Error",
Summary: message,
Updated: feed.Updated,
})
return feed
}