- 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.
76 lines
2.1 KiB
Go
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})
|
|
}
|