- 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.
237 lines
6.4 KiB
Go
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
|
|
}
|