Vellum/internal/media/handlers.go
mithral 1b11867efe Phase 6a: directory grouping, audiobooks and music playback
A book is a folder of chapters, not a file. The scanner gained a grouping
mode so a directory of tracks becomes one item with ordered files.

- registry: MediaType.Group (file | directory). Audiobooks and music group by
  directory; everything else stays one item per file.
- scanner: builds groups, upserts one item per group, marks items missing when
  their directory disappears, and keeps the file-level added/updated/missing
  accounting. Tracks are ordered naturally, so chapter 2 precedes chapter 10.
- natsort: one shared natural-order comparator, now used by both the scanner
  and the comics module (which had its own copy).
- media: GET /items/{id}/files/{fileID} streams one specific track. It
  verifies the file belongs to the item, so an item id cannot be paired with
  another item's file id.

No new module was needed for playback: the polymorphic progress table already
keys on file_id, so per-chapter resume fell out of the existing design. The
web player resumes the exact track and offset.

Verified: 3 chapters grouped into one book titled "Mistborn", natural order,
range request returned 206, mismatched file id 404s, per-track progress
round-trips.
2026-10-09 21:20:47 -04:00

320 lines
8.7 KiB
Go

package media
import (
"database/sql"
"errors"
"log/slog"
"net/http"
"os"
"path/filepath"
"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}/files/{fileID}", h.auth.Require(h.handleFileByID))
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
}
}
// handleFileByID streams one specific file of an item, for multi-file items
// such as audiobook chapters or album tracks.
func (h *Handlers) handleFileByID(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
fileID, err := strconv.ParseInt(r.PathValue("fileID"), 10, 64)
if err != nil || fileID <= 0 {
httpx.WriteError(w, http.StatusBadRequest, "invalid file id")
return
}
// Confirm the file belongs to this item, so an item id cannot be paired
// with another item's file id.
var relPath string
err = h.db.QueryRowContext(r.Context(),
`SELECT path FROM media_files WHERE id = ? AND item_id = ?`, fileID, it.ID).Scan(&relPath)
if errors.Is(err, sql.ErrNoRows) {
httpx.WriteError(w, http.StatusNotFound, "file not found")
return
}
if err != nil {
h.fail(w, "load file", err)
return
}
abs := filepath.Join(it.RootPath, filepath.FromSlash(relPath))
w.Header().Set("Content-Disposition", "inline")
if err := stream.ServeFile(w, r, abs); err != nil {
if errors.Is(err, os.ErrNotExist) {
httpx.WriteError(w, http.StatusNotFound, "file is not available")
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)
}