Vellum/internal/modules/ebooks/epub.go
mithral 5c63932419 Phase 3: EPUB module, file streaming, per-user progress
- stream: serving goes through http.ServeContent, so Range/If-Range/
  If-Modified-Since are correct for every media type by construction
- progress: the polymorphic store. One row per user/item/file; the PK includes
  the file so multi-track audiobooks resume per track later. Finished is
  inferred when position reaches duration.
- media: generic item endpoints (detail, file, cover, progress, recent). Every
  one routes through auth.CanAccessLibrary; an unauthorised item 404s.
- covers: a per-kind extractor registry. Extraction is lazy and recorded in
  the assets table. A missing cover is a placeholder, never a 500.
- modules/ebooks: EPUB parsing (container.xml -> OPF -> metadata, spine, cover
  with three fallback strategies), cover extraction to /config, and lazy
  metadata parsing so parser fixes need no rescan.

Verified against a generated EPUB: title/2 authors/language/publisher/spine,
cover served as PNG, range request returned 206 with correct Content-Range,
progress round-tripped.
2026-10-09 16:36:27 -04:00

237 lines
6.4 KiB
Go

// Package ebooks parses EPUB files and serves ebook-specific metadata.
//
// EPUB is a zip with an OPF package document describing metadata, a manifest,
// and a spine (reading order). Parsing is done on demand from the file itself;
// nothing here depends on a network service.
package ebooks
import (
"archive/zip"
"encoding/xml"
"fmt"
"io"
"path"
"strings"
)
// Metadata is the subset of EPUB metadata Vellum uses.
type Metadata struct {
Title string `json:"title"`
Authors []string `json:"authors"`
Language string `json:"language"`
Identifier string `json:"identifier"`
Publisher string `json:"publisher"`
Date string `json:"date"`
Description string `json:"description"`
SpineLength int `json:"spine_length"`
// CoverHref is the cover image path relative to the OPF directory.
CoverHref string `json:"-"`
}
// containerXML is META-INF/container.xml.
type containerXML struct {
Rootfiles []struct {
FullPath string `xml:"full-path,attr"`
} `xml:"rootfiles>rootfile"`
}
// opfXML is the package document. Field tags match by local name, so
// namespaced elements (dc:title, dc:creator) decode without prefix handling.
type opfXML struct {
Metadata struct {
Titles []string `xml:"title"`
Creators []string `xml:"creator"`
Language string `xml:"language"`
Identifier string `xml:"identifier"`
Publisher string `xml:"publisher"`
Date string `xml:"date"`
Description string `xml:"description"`
Metas []struct {
Name string `xml:"name,attr"`
Content string `xml:"content,attr"`
Property string `xml:"property,attr"`
Value string `xml:",chardata"`
} `xml:"meta"`
} `xml:"metadata"`
Manifest struct {
Items []struct {
ID string `xml:"id,attr"`
Href string `xml:"href,attr"`
MediaType string `xml:"media-type,attr"`
Properties string `xml:"properties,attr"`
} `xml:"item"`
} `xml:"manifest"`
Spine struct {
Itemrefs []struct {
IDRef string `xml:"idref,attr"`
} `xml:"itemref"`
} `xml:"spine"`
}
// Parse reads an EPUB's metadata and cover location.
func Parse(epubPath string) (Metadata, error) {
zr, err := zip.OpenReader(epubPath)
if err != nil {
return Metadata{}, fmt.Errorf("ebooks: open epub: %w", err)
}
defer zr.Close()
opfPath, err := findOPFPath(zr)
if err != nil {
return Metadata{}, err
}
data, err := readZipFile(zr, opfPath)
if err != nil {
return Metadata{}, fmt.Errorf("ebooks: read opf %s: %w", opfPath, err)
}
var opf opfXML
if err := xml.Unmarshal(data, &opf); err != nil {
return Metadata{}, fmt.Errorf("ebooks: parse opf: %w", err)
}
md := Metadata{
Language: strings.TrimSpace(opf.Metadata.Language),
Identifier: strings.TrimSpace(opf.Metadata.Identifier),
Publisher: strings.TrimSpace(opf.Metadata.Publisher),
Date: strings.TrimSpace(opf.Metadata.Date),
Description: strings.TrimSpace(opf.Metadata.Description),
}
for _, t := range opf.Metadata.Titles {
if s := strings.TrimSpace(t); s != "" {
md.Title = s
break
}
}
for _, c := range opf.Metadata.Creators {
if s := strings.TrimSpace(c); s != "" {
md.Authors = append(md.Authors, s)
}
}
md.SpineLength = len(opf.Spine.Itemrefs)
md.CoverHref = coverHref(opf)
// Hrefs in the OPF are relative to the OPF's directory.
md.CoverHref = joinOPF(path.Dir(opfPath), md.CoverHref)
return md, nil
}
// findOPFPath locates the package document via META-INF/container.xml.
func findOPFPath(zr *zip.ReadCloser) (string, error) {
data, err := readZipFile(zr, "META-INF/container.xml")
if err != nil {
return "", fmt.Errorf("ebooks: read container.xml: %w", err)
}
var c containerXML
if err := xml.Unmarshal(data, &c); err != nil {
return "", fmt.Errorf("ebooks: parse container.xml: %w", err)
}
for _, rf := range c.Rootfiles {
if p := strings.TrimSpace(rf.FullPath); p != "" {
return p, nil
}
}
return "", fmt.Errorf("ebooks: container.xml has no rootfile")
}
// coverHref picks the cover image from the manifest, trying in order:
// the EPUB3 properties="cover-image" marker, the EPUB2 <meta name="cover">
// reference, then any manifest image with "cover" in its id or href.
func coverHref(opf opfXML) string {
byID := make(map[string]string, len(opf.Manifest.Items))
for _, it := range opf.Manifest.Items {
byID[it.ID] = it.Href
}
for _, it := range opf.Manifest.Items {
if hasProp(it.Properties, "cover-image") {
return it.Href
}
}
for _, m := range opf.Metadata.Metas {
if strings.EqualFold(strings.TrimSpace(m.Name), "cover") {
if href, ok := byID[strings.TrimSpace(m.Content)]; ok {
return href
}
}
}
for _, it := range opf.Manifest.Items {
if !strings.HasPrefix(it.MediaType, "image/") {
continue
}
if strings.Contains(strings.ToLower(it.ID), "cover") || strings.Contains(strings.ToLower(it.Href), "cover") {
return it.Href
}
}
return ""
}
func hasProp(props, want string) bool {
for _, p := range strings.Fields(props) {
if p == want {
return true
}
}
return false
}
// joinOPF joins an OPF-relative href onto the OPF directory, using forward
// slashes (zip convention) and refusing to escape the archive root.
func joinOPF(dir, href string) string {
if href == "" {
return ""
}
joined := path.Clean(path.Join(dir, href))
if strings.HasPrefix(joined, "../") || joined == ".." {
return ""
}
return joined
}
// readZipFile reads one entry fully.
func readZipFile(zr *zip.ReadCloser, name string) ([]byte, error) {
f, err := zr.Open(name)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(io.LimitReader(f, 16<<20)) // 16 MiB is generous for an OPF
}
// OpenEntry opens one entry of the archive for streaming.
func OpenEntry(epubPath, name string) (io.ReadCloser, *zip.File, error) {
zr, err := zip.OpenReader(epubPath)
if err != nil {
return nil, nil, err
}
f, err := zr.Open(name)
if err != nil {
zr.Close()
return nil, nil, err
}
// Wrap so closing the reader also closes the archive.
return &entryReadCloser{rc: f, zr: zr}, findZipFile(zr, name), nil
}
func findZipFile(zr *zip.ReadCloser, name string) *zip.File {
for _, f := range zr.File {
if f.Name == name {
return f
}
}
return nil
}
type entryReadCloser struct {
rc io.ReadCloser
zr *zip.ReadCloser
}
func (e *entryReadCloser) Read(p []byte) (int, error) { return e.rc.Read(p) }
func (e *entryReadCloser) Close() error {
err := e.rc.Close()
_ = e.zr.Close()
return err
}