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.
This commit is contained in:
mithral 2026-10-09 16:36:27 -04:00
parent 5458b0a28a
commit 5c63932419
10 changed files with 1217 additions and 0 deletions

View file

@ -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,

82
internal/media/covers.go Normal file
View file

@ -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
}

278
internal/media/handlers.go Normal file
View file

@ -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)
}

71
internal/media/media.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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 <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
}

View file

@ -0,0 +1,141 @@
package ebooks
import (
"archive/zip"
"encoding/xml"
"os"
"path/filepath"
"testing"
)
const testOPF = `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>The Test Book</dc:title>
<dc:creator>Ada Lovelace</dc:creator>
<dc:creator>Charles Babbage</dc:creator>
<dc:language>en</dc:language>
<dc:identifier id="pub-id">urn:uuid:abc</dc:identifier>
<dc:publisher>Vellum Press</dc:publisher>
<dc:date>2026-01-01</dc:date>
<meta name="cover" content="cover-img"/>
</metadata>
<manifest>
<item id="cover-img" href="images/cover.png" media-type="image/png" properties="cover-image"/>
<item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
<item id="ch2" href="chapter2.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine>
<itemref idref="ch1"/>
<itemref idref="ch2"/>
</spine>
</package>`
const testContainer = `<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>`
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", "<html/>")
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 <meta name="cover">.
opfMeta := `<package><metadata><title>x</title><meta name="cover" content="c"/></metadata>
<manifest><item id="c" href="c.jpg" media-type="image/jpeg"/></manifest><spine/></package>`
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 := `<package><metadata><title>x</title></metadata>
<manifest><item id="i1" href="images/my-cover.png" media-type="image/png"/></manifest><spine/></package>`
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 := `<package><metadata><title>x</title></metadata>
<manifest><item id="i1" href="a.png" media-type="image/png"/></manifest><spine/></package>`
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
}

View file

@ -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})
}

155
internal/progress/store.go Normal file
View file

@ -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
}

100
internal/stream/stream.go Normal file
View file

@ -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)))
}