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

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

76 lines
2.1 KiB
Go

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