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:
parent
5458b0a28a
commit
5c63932419
10 changed files with 1217 additions and 0 deletions
|
|
@ -16,6 +16,8 @@ import (
|
||||||
"github.com/mithral/vellum/internal/core"
|
"github.com/mithral/vellum/internal/core"
|
||||||
"github.com/mithral/vellum/internal/httpapi"
|
"github.com/mithral/vellum/internal/httpapi"
|
||||||
"github.com/mithral/vellum/internal/library"
|
"github.com/mithral/vellum/internal/library"
|
||||||
|
"github.com/mithral/vellum/internal/media"
|
||||||
|
"github.com/mithral/vellum/internal/modules/ebooks"
|
||||||
"github.com/mithral/vellum/migrations"
|
"github.com/mithral/vellum/migrations"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -71,6 +73,8 @@ func run() error {
|
||||||
api.SetAuth(authenticator)
|
api.SetAuth(authenticator)
|
||||||
|
|
||||||
library.NewHandlers(db, log, authenticator).Register(api.Mux())
|
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{
|
srv := &http.Server{
|
||||||
Addr: cfg.Listen,
|
Addr: cfg.Listen,
|
||||||
|
|
|
||||||
82
internal/media/covers.go
Normal file
82
internal/media/covers.go
Normal 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
278
internal/media/handlers.go
Normal 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
71
internal/media/media.go
Normal 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
|
||||||
|
}
|
||||||
73
internal/modules/ebooks/cover.go
Normal file
73
internal/modules/ebooks/cover.go
Normal 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
|
||||||
|
}
|
||||||
237
internal/modules/ebooks/epub.go
Normal file
237
internal/modules/ebooks/epub.go
Normal 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
|
||||||
|
}
|
||||||
141
internal/modules/ebooks/epub_test.go
Normal file
141
internal/modules/ebooks/epub_test.go
Normal 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
|
||||||
|
}
|
||||||
76
internal/modules/ebooks/handlers.go
Normal file
76
internal/modules/ebooks/handlers.go
Normal 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
155
internal/progress/store.go
Normal 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
100
internal/stream/stream.go
Normal 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)))
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue