diff --git a/cmd/vellum/main.go b/cmd/vellum/main.go
index 1ed253a..d10c125 100644
--- a/cmd/vellum/main.go
+++ b/cmd/vellum/main.go
@@ -16,6 +16,8 @@ import (
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/httpapi"
"github.com/mithral/vellum/internal/library"
+ "github.com/mithral/vellum/internal/media"
+ "github.com/mithral/vellum/internal/modules/ebooks"
"github.com/mithral/vellum/migrations"
)
@@ -71,6 +73,8 @@ func run() error {
api.SetAuth(authenticator)
library.NewHandlers(db, log, authenticator).Register(api.Mux())
+ media.NewHandlers(db, log, authenticator, cfg.ConfigDir).Register(api.Mux())
+ ebooks.NewHandlers(db, log, authenticator).Register(api.Mux())
srv := &http.Server{
Addr: cfg.Listen,
diff --git a/internal/media/covers.go b/internal/media/covers.go
new file mode 100644
index 0000000..c85b8bd
--- /dev/null
+++ b/internal/media/covers.go
@@ -0,0 +1,82 @@
+package media
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+)
+
+// CoverExtractor pulls cover art out of a media file and writes it under
+// cacheDir, returning the path to the written image.
+//
+// Modules register one per media kind. It runs lazily on first request and the
+// result is recorded in the assets table, so the cost is paid once.
+type CoverExtractor func(ctx context.Context, db *sql.DB, item Item, absPath, cacheDir string) (string, error)
+
+var (
+ coverMu sync.RWMutex
+ coverExtractors = map[string]CoverExtractor{}
+)
+
+// RegisterCoverExtractor installs the extractor for a media kind. A later
+// registration replaces an earlier one.
+func RegisterCoverExtractor(kind string, fn CoverExtractor) {
+ coverMu.Lock()
+ defer coverMu.Unlock()
+ coverExtractors[kind] = fn
+}
+
+// CoverPath returns the cached cover path for an item, extracting it if
+// necessary. The bool is false when no cover could be produced — which is a
+// normal outcome, not an error, so callers return a placeholder rather than a
+// 500.
+func CoverPath(ctx context.Context, db *sql.DB, item Item, cacheDir string) (string, bool, error) {
+ // Already extracted and recorded?
+ var path string
+ err := db.QueryRowContext(ctx, `
+ SELECT path FROM assets WHERE item_id = ? AND role = 'cover' LIMIT 1`, item.ID).Scan(&path)
+ if err == nil {
+ if _, statErr := os.Stat(path); statErr == nil {
+ return path, true, nil
+ }
+ // Recorded but gone: fall through and re-extract.
+ } else if err != sql.ErrNoRows {
+ return "", false, fmt.Errorf("media: lookup cover: %w", err)
+ }
+
+ coverMu.RLock()
+ extract, ok := coverExtractors[item.Kind]
+ coverMu.RUnlock()
+ if !ok {
+ return "", false, nil
+ }
+
+ outPath, err := extract(ctx, db, item, item.AbsPath(), cacheDir)
+ if err != nil {
+ return "", false, fmt.Errorf("media: extract cover: %w", err)
+ }
+ if outPath == "" {
+ return "", false, nil
+ }
+
+ if _, err := db.ExecContext(ctx, `
+ INSERT INTO assets (item_id, role, path) VALUES (?, 'cover', ?)
+ ON CONFLICT (item_id, role, path) DO NOTHING`, item.ID, outPath); err != nil {
+ return "", false, fmt.Errorf("media: record cover: %w", err)
+ }
+ return outPath, true, nil
+}
+
+// ── helpers for extractors ─────────────────────────────────────────────
+
+// CoverCacheDir returns (and creates) the per-kind cover cache directory.
+func CoverCacheDir(cacheDir, kind string) (string, error) {
+ dir := filepath.Join(cacheDir, "covers", kind)
+ if err := os.MkdirAll(dir, 0o750); err != nil {
+ return "", err
+ }
+ return dir, nil
+}
diff --git a/internal/media/handlers.go b/internal/media/handlers.go
new file mode 100644
index 0000000..ec24f11
--- /dev/null
+++ b/internal/media/handlers.go
@@ -0,0 +1,278 @@
+package media
+
+import (
+ "database/sql"
+ "errors"
+ "log/slog"
+ "net/http"
+ "os"
+ "strconv"
+
+ "github.com/mithral/vellum/internal/auth"
+ "github.com/mithral/vellum/internal/httpx"
+ "github.com/mithral/vellum/internal/progress"
+ "github.com/mithral/vellum/internal/stream"
+)
+
+// Handlers serves the generic per-item endpoints.
+type Handlers struct {
+ db *sql.DB
+ log *slog.Logger
+ auth *auth.Authenticator
+ cacheDir string
+}
+
+// NewHandlers builds the media HTTP layer. cacheDir is where covers and
+// extracted pages are written.
+func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator, cacheDir string) *Handlers {
+ return &Handlers{db: db, log: log, auth: a, cacheDir: cacheDir}
+}
+
+// Register mounts the routes.
+func (h *Handlers) Register(mux *http.ServeMux) {
+ mux.Handle("GET /api/v1/items/{id}", h.auth.Require(h.handleItem))
+ mux.Handle("GET /api/v1/items/{id}/file", h.auth.Require(h.handleFile))
+ mux.Handle("GET /api/v1/items/{id}/cover", h.auth.Require(h.handleCover))
+ mux.Handle("GET /api/v1/items/{id}/progress", h.auth.Require(h.handleGetProgress))
+ mux.Handle("PUT /api/v1/items/{id}/progress", h.auth.Require(h.handlePutProgress))
+ mux.Handle("GET /api/v1/progress/recent", h.auth.Require(h.handleRecent))
+}
+
+func (h *Handlers) handleItem(w http.ResponseWriter, r *http.Request, u auth.User) {
+ it, ok := h.authorized(w, r, u)
+ if !ok {
+ return
+ }
+
+ files, err := h.files(r, it.ID)
+ if err != nil {
+ h.fail(w, "list files", err)
+ return
+ }
+
+ prog, has, err := progress.Get(r.Context(), h.db, u.ID, it.ID)
+ if err != nil {
+ h.fail(w, "get progress", err)
+ return
+ }
+
+ out := map[string]any{
+ "item": map[string]any{
+ "id": it.ID,
+ "library_id": it.LibraryID,
+ "kind": it.Kind,
+ "path": it.Path,
+ "title": it.Title,
+ "year": it.Year,
+ "missing": it.Missing,
+ },
+ "files": files,
+ "has_cover": h.hasCover(r, it),
+ }
+ if has {
+ out["progress"] = prog
+ }
+ httpx.WriteJSON(w, http.StatusOK, out)
+}
+
+func (h *Handlers) handleFile(w http.ResponseWriter, r *http.Request, u auth.User) {
+ it, ok := h.authorized(w, r, u)
+ if !ok {
+ return
+ }
+
+ // Toolbar download vs inline. Inline is the default for readers.
+ disposition := "inline"
+ if r.URL.Query().Get("download") == "1" {
+ disposition = "attachment"
+ allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID)
+ if err != nil || !allowed {
+ httpx.WriteError(w, http.StatusForbidden, "download not permitted")
+ return
+ }
+ }
+ w.Header().Set("Content-Disposition", disposition+`; filename="`+sanitizeFilename(it.Path)+`"`)
+
+ if err := stream.ServeFile(w, r, it.AbsPath()); err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ httpx.WriteError(w, http.StatusNotFound, "file is not available")
+ return
+ }
+ if errors.Is(err, stream.ErrNotAFile) {
+ httpx.WriteError(w, http.StatusBadRequest, "item is not a file")
+ return
+ }
+ h.fail(w, "serve file", err)
+ return
+ }
+}
+
+func (h *Handlers) handleCover(w http.ResponseWriter, r *http.Request, u auth.User) {
+ it, ok := h.authorized(w, r, u)
+ if !ok {
+ return
+ }
+
+ path, found, err := CoverPath(r.Context(), h.db, it, h.cacheDir)
+ if err != nil {
+ h.fail(w, "cover", err)
+ return
+ }
+ if !found {
+ httpx.WriteError(w, http.StatusNotFound, "no cover available")
+ return
+ }
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+ if err := stream.ServeFile(w, r, path); err != nil {
+ h.fail(w, "serve cover", err)
+ return
+ }
+}
+
+func (h *Handlers) handleGetProgress(w http.ResponseWriter, r *http.Request, u auth.User) {
+ it, ok := h.authorized(w, r, u)
+ if !ok {
+ return
+ }
+ prog, found, err := progress.Get(r.Context(), h.db, u.ID, it.ID)
+ if err != nil {
+ h.fail(w, "get progress", err)
+ return
+ }
+ if !found {
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"progress": nil})
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"progress": prog})
+}
+
+func (h *Handlers) handlePutProgress(w http.ResponseWriter, r *http.Request, u auth.User) {
+ it, ok := h.authorized(w, r, u)
+ if !ok {
+ return
+ }
+
+ var req struct {
+ FileID int64 `json:"file_id"`
+ Position *int64 `json:"position"`
+ Duration int64 `json:"duration"`
+ Percent float64 `json:"percent"`
+ Finished bool `json:"finished"`
+ }
+ if err := httpx.DecodeJSON(r, &req); err != nil {
+ httpx.WriteError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ p := progress.Progress{
+ ItemID: it.ID,
+ FileID: req.FileID,
+ Kind: it.Kind,
+ Duration: req.Duration,
+ Percent: req.Percent,
+ Finished: req.Finished,
+ }
+ if req.Position != nil {
+ p.Position = *req.Position
+ }
+
+ saved, err := progress.Put(r.Context(), h.db, u.ID, p)
+ if errors.Is(err, progress.ErrNoFile) {
+ httpx.WriteError(w, http.StatusConflict, "item has no file to track progress against")
+ return
+ }
+ if err != nil {
+ h.fail(w, "save progress", err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"progress": saved})
+}
+
+func (h *Handlers) handleRecent(w http.ResponseWriter, r *http.Request, u auth.User) {
+ items, err := progress.RecentlyUpdated(r.Context(), h.db, u.ID, 20)
+ if err != nil {
+ h.fail(w, "recent progress", err)
+ return
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"progress": items})
+}
+
+// ── helpers ────────────────────────────────────────────────────────────
+
+// authorized loads an item and confirms the caller may read its library.
+// On failure it has already written the response.
+func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.User) (Item, bool) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil || id <= 0 {
+ httpx.WriteError(w, http.StatusBadRequest, "invalid item id")
+ return Item{}, false
+ }
+
+ it, err := LoadItem(r.Context(), h.db, id)
+ if errors.Is(err, ErrItemNotFound) {
+ httpx.WriteError(w, http.StatusNotFound, "item not found")
+ return Item{}, false
+ }
+ if err != nil {
+ h.fail(w, "load item", err)
+ return Item{}, false
+ }
+
+ allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID)
+ if err != nil {
+ h.fail(w, "check access", err)
+ return Item{}, false
+ }
+ if !allowed {
+ httpx.WriteError(w, http.StatusNotFound, "item not found")
+ return Item{}, false
+ }
+ return it, true
+}
+
+func (h *Handlers) files(r *http.Request, itemID int64) ([]FileEntry, error) {
+ rows, err := h.db.QueryContext(r.Context(), `
+ SELECT id, path, size, container, missing FROM media_files WHERE item_id = ? ORDER BY id`, itemID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ out := []FileEntry{}
+ for rows.Next() {
+ var f FileEntry
+ var missing int
+ if err := rows.Scan(&f.ID, &f.Path, &f.Size, &f.Container, &missing); err != nil {
+ return nil, err
+ }
+ f.Missing = missing == 1
+ out = append(out, f)
+ }
+ return out, rows.Err()
+}
+
+func (h *Handlers) hasCover(r *http.Request, it Item) bool {
+ var n int
+ _ = h.db.QueryRowContext(r.Context(),
+ `SELECT count(*) FROM assets WHERE item_id = ? AND role = 'cover'`, it.ID).Scan(&n)
+ return n > 0
+}
+
+func (h *Handlers) fail(w http.ResponseWriter, what string, err error) {
+ h.log.Error(what, "err", err)
+ httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
+}
+
+// sanitizeFilename strips characters that break a Content-Disposition header.
+func sanitizeFilename(name string) string {
+ out := make([]rune, 0, len(name))
+ for _, r := range name {
+ switch {
+ case r == '"' || r == '\\' || r == '\n' || r == '\r':
+ out = append(out, '_')
+ default:
+ out = append(out, r)
+ }
+ }
+ return string(out)
+}
diff --git a/internal/media/media.go b/internal/media/media.go
new file mode 100644
index 0000000..6eeb5fc
--- /dev/null
+++ b/internal/media/media.go
@@ -0,0 +1,71 @@
+// Package media serves generic item endpoints: detail, the raw file, cover
+// art, and progress. It knows nothing about any specific media format; modules
+// register cover extractors and expose their own metadata endpoints.
+package media
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "path/filepath"
+)
+
+// ErrItemNotFound means no item matched.
+var ErrItemNotFound = errors.New("media: item not found")
+
+// Item is one media item plus the root of its library.
+type Item struct {
+ ID int64
+ LibraryID int64
+ Kind string
+ Path string // path relative to the library root
+ Title string
+ Year int
+ Missing bool
+
+ // RootPath is the absolute library root, so AbsPath can be computed.
+ RootPath string
+}
+
+// AbsPath returns the absolute filesystem path of the item's file.
+//
+// item.path is stored relative to the library root and the root is stored
+// resolved, so this join cannot escape the library.
+func (i Item) AbsPath() string {
+ return filepath.Join(i.RootPath, filepath.FromSlash(i.Path))
+}
+
+// LoadItem loads an item and its library root.
+func LoadItem(ctx context.Context, db *sql.DB, id int64) (Item, error) {
+ var it Item
+ err := db.QueryRowContext(ctx, `
+ SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year, 0), i.missing, l.root_path
+ FROM media_items i
+ JOIN libraries l ON l.id = i.library_id
+ WHERE i.id = ?`, id,
+ ).Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, &it.Year, &it.Missing, &it.RootPath)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Item{}, ErrItemNotFound
+ }
+ if err != nil {
+ return Item{}, fmt.Errorf("media: load item: %w", err)
+ }
+ return it, nil
+}
+
+// FileEntry is one physical file backing an item.
+type FileEntry struct {
+ ID int64 `json:"id"`
+ Path string `json:"path"`
+ Size int64 `json:"size"`
+ Container string `json:"container"`
+ Missing bool `json:"missing"`
+}
+
+// FileCount returns how many files back an item.
+func FileCount(ctx context.Context, db *sql.DB, itemID int64) (int, error) {
+ var n int
+ err := db.QueryRowContext(ctx, `SELECT count(*) FROM media_files WHERE item_id = ?`, itemID).Scan(&n)
+ return n, err
+}
diff --git a/internal/modules/ebooks/cover.go b/internal/modules/ebooks/cover.go
new file mode 100644
index 0000000..7614065
--- /dev/null
+++ b/internal/modules/ebooks/cover.go
@@ -0,0 +1,73 @@
+package ebooks
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "io"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "github.com/mithral/vellum/internal/media"
+)
+
+// Kind is the media kind this module owns.
+const Kind = "ebooks"
+
+func init() {
+ media.RegisterCoverExtractor(Kind, ExtractCover)
+}
+
+// ExtractCover writes the EPUB's cover image into the cache and returns its
+// path. A missing cover is not an error: it returns ("", nil) and the caller
+// serves a placeholder.
+func ExtractCover(ctx context.Context, db *sql.DB, item media.Item, absPath, cacheDir string) (string, error) {
+ md, err := Parse(absPath)
+ if err != nil {
+ return "", err
+ }
+ if md.CoverHref == "" {
+ return "", nil
+ }
+
+ dir, err := media.CoverCacheDir(cacheDir, Kind)
+ if err != nil {
+ return "", err
+ }
+
+ ext := strings.ToLower(path.Ext(md.CoverHref))
+ if ext == "" {
+ ext = ".jpg"
+ }
+ outPath := filepath.Join(dir, fmt.Sprintf("%d%s", item.ID, ext))
+
+ rc, _, err := OpenEntry(absPath, md.CoverHref)
+ if err != nil {
+ // Cover declared but not present in the archive: not fatal.
+ return "", nil
+ }
+ defer rc.Close()
+
+ tmp := outPath + ".tmp"
+ f, err := os.Create(tmp)
+ if err != nil {
+ return "", fmt.Errorf("ebooks: create cover: %w", err)
+ }
+ if _, err := io.Copy(f, io.LimitReader(rc, 32<<20)); err != nil {
+ f.Close()
+ _ = os.Remove(tmp)
+ return "", fmt.Errorf("ebooks: write cover: %w", err)
+ }
+ if err := f.Close(); err != nil {
+ _ = os.Remove(tmp)
+ return "", err
+ }
+ // Atomic replace so a concurrent reader never sees a half-written image.
+ if err := os.Rename(tmp, outPath); err != nil {
+ _ = os.Remove(tmp)
+ return "", err
+ }
+ return outPath, nil
+}
diff --git a/internal/modules/ebooks/epub.go b/internal/modules/ebooks/epub.go
new file mode 100644
index 0000000..3dd1d36
--- /dev/null
+++ b/internal/modules/ebooks/epub.go
@@ -0,0 +1,237 @@
+// 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
+// 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
+}
diff --git a/internal/modules/ebooks/epub_test.go b/internal/modules/ebooks/epub_test.go
new file mode 100644
index 0000000..7790e34
--- /dev/null
+++ b/internal/modules/ebooks/epub_test.go
@@ -0,0 +1,141 @@
+package ebooks
+
+import (
+ "archive/zip"
+ "encoding/xml"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+const testOPF = `
+