From 5c639324194b85432a90c9121403bb2a3a4aea0a Mon Sep 17 00:00:00 2001
From: mithral
Date: Fri, 9 Oct 2026 16:36:27 -0400
Subject: [PATCH] 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.
---
cmd/vellum/main.go | 4 +
internal/media/covers.go | 82 ++++++++
internal/media/handlers.go | 278 +++++++++++++++++++++++++++
internal/media/media.go | 71 +++++++
internal/modules/ebooks/cover.go | 73 +++++++
internal/modules/ebooks/epub.go | 237 +++++++++++++++++++++++
internal/modules/ebooks/epub_test.go | 141 ++++++++++++++
internal/modules/ebooks/handlers.go | 76 ++++++++
internal/progress/store.go | 155 +++++++++++++++
internal/stream/stream.go | 100 ++++++++++
10 files changed, 1217 insertions(+)
create mode 100644 internal/media/covers.go
create mode 100644 internal/media/handlers.go
create mode 100644 internal/media/media.go
create mode 100644 internal/modules/ebooks/cover.go
create mode 100644 internal/modules/ebooks/epub.go
create mode 100644 internal/modules/ebooks/epub_test.go
create mode 100644 internal/modules/ebooks/handlers.go
create mode 100644 internal/progress/store.go
create mode 100644 internal/stream/stream.go
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 = `
+
+
+ The Test Book
+ Ada Lovelace
+ Charles Babbage
+ en
+ urn:uuid:abc
+ Vellum Press
+ 2026-01-01
+
+
+
+
+
+
+
+
+
+
+
+`
+
+const testContainer = `
+
+
+`
+
+func writeEPUB(t *testing.T, opf, container string) string {
+ t.Helper()
+ p := filepath.Join(t.TempDir(), "test.epub")
+ f, err := os.Create(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+
+ zw := zip.NewWriter(f)
+ add := func(name, body string) {
+ w, err := zw.Create(name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := w.Write([]byte(body)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ add("mimetype", "application/epub+zip")
+ add("META-INF/container.xml", container)
+ add("OEBPS/content.opf", opf)
+ add("OEBPS/images/cover.png", "not-a-real-png")
+ add("OEBPS/chapter1.xhtml", "")
+ if err := zw.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+func TestParseMetadata(t *testing.T) {
+ md, err := Parse(writeEPUB(t, testOPF, testContainer))
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ if md.Title != "The Test Book" {
+ t.Errorf("Title = %q", md.Title)
+ }
+ if len(md.Authors) != 2 || md.Authors[0] != "Ada Lovelace" {
+ t.Errorf("Authors = %v", md.Authors)
+ }
+ if md.Language != "en" || md.Publisher != "Vellum Press" || md.Date != "2026-01-01" {
+ t.Errorf("metadata = %+v", md)
+ }
+ if md.SpineLength != 2 {
+ t.Errorf("SpineLength = %d, want 2", md.SpineLength)
+ }
+ // CoverHref must be resolved relative to the OPF directory.
+ if md.CoverHref != "OEBPS/images/cover.png" {
+ t.Errorf("CoverHref = %q, want OEBPS/images/cover.png", md.CoverHref)
+ }
+}
+
+func TestCoverHrefFallbacks(t *testing.T) {
+ // No properties="cover-image"; rely on .
+ opfMeta := `x
+ `
+ if got := coverHref(mustParseOPF(t, opfMeta)); got != "c.jpg" {
+ t.Errorf("meta cover fallback = %q, want c.jpg", got)
+ }
+
+ // No marker at all; fall back to an image whose href mentions cover.
+ opfPlain := `x
+ `
+ if got := coverHref(mustParseOPF(t, opfPlain)); got != "images/my-cover.png" {
+ t.Errorf("href heuristics = %q, want images/my-cover.png", got)
+ }
+
+ // No candidate at all.
+ opfNone := `x
+ `
+ if got := coverHref(mustParseOPF(t, opfNone)); got != "" {
+ t.Errorf("no-cover case = %q, want empty", got)
+ }
+}
+
+func TestJoinOPFRefusesEscape(t *testing.T) {
+ if got := joinOPF("OEBPS", "../../etc/passwd"); got != "" {
+ t.Errorf("joinOPF escaped the archive: %q", got)
+ }
+ if got := joinOPF("OEBPS", "images/c.png"); got != "OEBPS/images/c.png" {
+ t.Errorf("joinOPF = %q", got)
+ }
+}
+
+func TestParseRejectsNonEPUB(t *testing.T) {
+ p := filepath.Join(t.TempDir(), "not.epub")
+ if err := os.WriteFile(p, []byte("this is not a zip"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Parse(p); err == nil {
+ t.Error("Parse succeeded on a non-zip file")
+ }
+}
+
+func mustParseOPF(t *testing.T, s string) opfXML {
+ t.Helper()
+ var o opfXML
+ if err := xml.Unmarshal([]byte(s), &o); err != nil {
+ t.Fatal(err)
+ }
+ return o
+}
diff --git a/internal/modules/ebooks/handlers.go b/internal/modules/ebooks/handlers.go
new file mode 100644
index 0000000..41deab6
--- /dev/null
+++ b/internal/modules/ebooks/handlers.go
@@ -0,0 +1,76 @@
+package ebooks
+
+import (
+ "database/sql"
+ "errors"
+ "log/slog"
+ "net/http"
+ "strconv"
+
+ "github.com/mithral/vellum/internal/auth"
+ "github.com/mithral/vellum/internal/httpx"
+ "github.com/mithral/vellum/internal/media"
+)
+
+// Handlers serves ebook-specific endpoints.
+type Handlers struct {
+ db *sql.DB
+ log *slog.Logger
+ auth *auth.Authenticator
+}
+
+// NewHandlers builds the ebook HTTP layer.
+func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers {
+ return &Handlers{db: db, log: log, auth: a}
+}
+
+// Register mounts the routes.
+func (h *Handlers) Register(mux *http.ServeMux) {
+ mux.Handle("GET /api/v1/items/{id}/ebook", h.auth.Require(h.handleMetadata))
+}
+
+// handleMetadata parses the EPUB on demand and returns its metadata.
+//
+// Parsing on demand rather than at scan time keeps the scanner fast and means
+// a fix to the parser applies immediately, without a rescan.
+func (h *Handlers) handleMetadata(w http.ResponseWriter, r *http.Request, u auth.User) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil || id <= 0 {
+ httpx.WriteError(w, http.StatusBadRequest, "invalid item id")
+ return
+ }
+
+ it, err := media.LoadItem(r.Context(), h.db, id)
+ if errors.Is(err, media.ErrItemNotFound) {
+ httpx.WriteError(w, http.StatusNotFound, "item not found")
+ return
+ }
+ if err != nil {
+ h.log.Error("load item", "err", err)
+ httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
+ return
+ }
+
+ allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID)
+ if err != nil || !allowed {
+ httpx.WriteError(w, http.StatusNotFound, "item not found")
+ return
+ }
+ if it.Kind != Kind {
+ httpx.WriteError(w, http.StatusBadRequest, "item is not an ebook")
+ return
+ }
+
+ md, err := Parse(it.AbsPath())
+ if err != nil {
+ h.log.Warn("parse epub", "item", it.ID, "err", err)
+ httpx.WriteError(w, http.StatusUnprocessableEntity, "could not parse epub")
+ return
+ }
+
+ // Fall back to the scanner's title when the OPF has none.
+ if md.Title == "" {
+ md.Title = it.Title
+ }
+ httpx.WriteJSON(w, http.StatusOK, map[string]any{"metadata": md})
+}
diff --git a/internal/progress/store.go b/internal/progress/store.go
new file mode 100644
index 0000000..601c839
--- /dev/null
+++ b/internal/progress/store.go
@@ -0,0 +1,155 @@
+// Package progress stores and retrieves per-user playback/reading position.
+//
+// One table, one shape, every media kind: milliseconds for audio and video, a
+// spine index or page number for text. Callers that don't fit the integer
+// model should encode their position into it rather than adding a column.
+package progress
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "time"
+)
+
+// ErrNoFile means an item has no media_files row to attach progress to.
+var ErrNoFile = errors.New("progress: item has no file to track")
+
+// Progress is one user's position in one item's file.
+type Progress struct {
+ ItemID int64 `json:"item_id"`
+ FileID int64 `json:"file_id"`
+ Kind string `json:"kind"`
+ Position int64 `json:"position"`
+ Duration int64 `json:"duration"`
+ Percent float64 `json:"percent"`
+ Finished bool `json:"finished"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// Get returns the most recently updated progress row for a user and item.
+// The second return value is false when the user has never touched the item.
+func Get(ctx context.Context, db *sql.DB, userID, itemID int64) (Progress, bool, error) {
+ var (
+ p Progress
+ finished int
+ updatedAt string
+ )
+ err := db.QueryRowContext(ctx, `
+ SELECT item_id, file_id, kind, position, duration, percent, finished, updated_at
+ FROM progress
+ WHERE user_id = ? AND item_id = ?
+ ORDER BY updated_at DESC
+ LIMIT 1`, userID, itemID,
+ ).Scan(&p.ItemID, &p.FileID, &p.Kind, &p.Position, &p.Duration, &p.Percent, &finished, &updatedAt)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Progress{}, false, nil
+ }
+ if err != nil {
+ return Progress{}, false, fmt.Errorf("progress: get: %w", err)
+ }
+ p.Finished = finished == 1
+ p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt)
+ return p, true, nil
+}
+
+// Put upserts progress. A zero FileID resolves to the item's first file, which
+// is what single-file items (epub, cbz) always are.
+func Put(ctx context.Context, db *sql.DB, userID int64, p Progress) (Progress, error) {
+ if p.FileID == 0 {
+ fileID, err := firstFileID(ctx, db, p.ItemID)
+ if err != nil {
+ return Progress{}, err
+ }
+ p.FileID = fileID
+ }
+ if p.Kind == "" {
+ _ = db.QueryRowContext(ctx, `SELECT kind FROM media_items WHERE id = ?`, p.ItemID).Scan(&p.Kind)
+ }
+
+ if p.Percent < 0 {
+ p.Percent = 0
+ }
+ if p.Percent > 1 {
+ p.Percent = 1
+ }
+ // A timed item at 100% is finished even if the client did not say so.
+ if p.Duration > 0 && p.Position >= p.Duration {
+ p.Percent = 1
+ p.Finished = true
+ }
+
+ now := time.Now().UTC()
+ _, err := db.ExecContext(ctx, `
+ INSERT INTO progress (user_id, item_id, file_id, kind, position, duration, percent, finished, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (user_id, item_id, file_id)
+ DO UPDATE SET kind = excluded.kind, position = excluded.position,
+ duration = excluded.duration, percent = excluded.percent,
+ finished = excluded.finished, updated_at = excluded.updated_at`,
+ userID, p.ItemID, p.FileID, p.Kind, p.Position, p.Duration, p.Percent, boolInt(p.Finished),
+ now.Format(time.RFC3339Nano))
+ if err != nil {
+ return Progress{}, fmt.Errorf("progress: put: %w", err)
+ }
+ p.UpdatedAt = now
+ return p, nil
+}
+
+// Delete clears progress for a user and item.
+func Delete(ctx context.Context, db *sql.DB, userID, itemID int64) error {
+ _, err := db.ExecContext(ctx, `DELETE FROM progress WHERE user_id = ? AND item_id = ?`, userID, itemID)
+ return err
+}
+
+// RecentlyUpdated powers a "Continue reading/watching" shelf.
+func RecentlyUpdated(ctx context.Context, db *sql.DB, userID int64, limit int) ([]Progress, error) {
+ if limit <= 0 || limit > 200 {
+ limit = 20
+ }
+ rows, err := db.QueryContext(ctx, `
+ SELECT item_id, file_id, kind, position, duration, percent, finished, updated_at
+ FROM progress
+ WHERE user_id = ? AND finished = 0
+ ORDER BY updated_at DESC
+ LIMIT ?`, userID, limit)
+ if err != nil {
+ return nil, fmt.Errorf("progress: recent: %w", err)
+ }
+ defer rows.Close()
+
+ out := []Progress{}
+ for rows.Next() {
+ var p Progress
+ var finished int
+ var updatedAt string
+ if err := rows.Scan(&p.ItemID, &p.FileID, &p.Kind, &p.Position, &p.Duration, &p.Percent, &finished, &updatedAt); err != nil {
+ return nil, err
+ }
+ p.Finished = finished == 1
+ p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt)
+ out = append(out, p)
+ }
+ return out, rows.Err()
+}
+
+func firstFileID(ctx context.Context, db *sql.DB, itemID int64) (int64, error) {
+ var id int64
+ err := db.QueryRowContext(ctx,
+ `SELECT id FROM media_files WHERE item_id = ? ORDER BY id LIMIT 1`, itemID).Scan(&id)
+ if errors.Is(err, sql.ErrNoRows) {
+ return 0, ErrNoFile
+ }
+ if err != nil {
+ return 0, fmt.Errorf("progress: first file: %w", err)
+ }
+ return id, nil
+}
+
+func boolInt(b bool) int {
+ if b {
+ return 1
+ }
+ return 0
+}
diff --git a/internal/stream/stream.go b/internal/stream/stream.go
new file mode 100644
index 0000000..a70acaf
--- /dev/null
+++ b/internal/stream/stream.go
@@ -0,0 +1,100 @@
+// Package stream serves media files with correct HTTP range semantics.
+//
+// Everything Vellum serves — epubs, comic pages, audio tracks, video — goes
+// through http.ServeContent, which implements Range, If-Range,
+// If-Modified-Since, and multi-range responses. Hand-rolling that is how a
+// media server ends up with seek bugs in exactly one player.
+package stream
+
+import (
+ "errors"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// ErrNotAFile means the requested path is a directory or otherwise unservable.
+var ErrNotAFile = errors.New("stream: not a regular file")
+
+// contentTypes maps extensions to MIME types for the media Vellum serves.
+// Unknown extensions fall back to application/octet-stream, which browsers
+// download rather than mis-render.
+var contentTypes = map[string]string{
+ ".epub": "application/epub+zip",
+ ".pdf": "application/pdf",
+ // Comics are containers; pages are served as images, but the archive
+ // itself may be downloaded.
+ ".cbz": "application/vnd.comicbook+zip",
+ ".cbr": "application/vnd.comicbook-rar",
+ // Audio.
+ ".mp3": "audio/mpeg",
+ ".m4a": "audio/mp4",
+ ".m4b": "audio/mp4",
+ ".aac": "audio/aac",
+ ".flac": "audio/flac",
+ ".opus": "audio/ogg",
+ ".ogg": "audio/ogg",
+ ".wav": "audio/wav",
+ // Video.
+ ".mp4": "video/mp4",
+ ".m4v": "video/mp4",
+ ".mkv": "video/x-matroska",
+ ".webm": "video/webm",
+ ".mov": "video/quicktime",
+ // Images (comic pages, covers, photos).
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".png": "image/png",
+ ".webp": "image/webp",
+ ".gif": "image/gif",
+ ".avif": "image/avif",
+ ".heic": "image/heic",
+ ".svg": "image/svg+xml",
+}
+
+// ContentType returns the MIME type for a path by extension.
+func ContentType(path string) string {
+ if ct, ok := contentTypes[strings.ToLower(filepath.Ext(path))]; ok {
+ return ct
+ }
+ return "application/octet-stream"
+}
+
+// ServeFile streams a file from disk, honouring Range requests.
+//
+// It returns ErrNotAFile for directories. Other errors (missing file, denied
+// permission) are returned before any response is written, so the caller can
+// still choose a status code.
+func ServeFile(w http.ResponseWriter, r *http.Request, path string) error {
+ f, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ info, err := f.Stat()
+ if err != nil {
+ return err
+ }
+ if !info.Mode().IsRegular() {
+ return ErrNotAFile
+ }
+
+ w.Header().Set("Content-Type", ContentType(path))
+ // Media is immutable in practice: a changed file gets a new mtime, so
+ // letting the browser cache aggressively is safe and saves bandwidth on
+ // large audio/video.
+ w.Header().Set("Accept-Ranges", "bytes")
+
+ http.ServeContent(w, r, info.Name(), info.ModTime(), f)
+ return nil
+}
+
+// ServeBytes writes an in-memory blob with a content type, mostly for
+// generated covers and extracted comic pages.
+func ServeBytes(w http.ResponseWriter, r *http.Request, name string, data []byte) {
+ w.Header().Set("Content-Type", ContentType(name))
+ http.ServeContent(w, r, name, time.Time{}, strings.NewReader(string(data)))
+}