Vellum/internal/library/handlers.go
mithral 0212d089ec Phase 6c: photo thumbnails and viewer
- photos: generates a downscaled JPEG thumbnail per image, because serving a
  folder of 12-megapixel originals to a grid is unusable on a TV box. Uses
  CatmullRom scaling via golang.org/x/image; decodes jpeg/png/gif/webp.
- an undecodable format returns no thumbnail rather than an error, so the grid
  falls back to a placeholder.
- images already smaller than the 512px ceiling are passed through untouched.
- library items endpoint now returns library_id, which the viewer needs to
  find its siblings for navigation.
- web: PhotoViewer with keyboard and button navigation within the library.

Verified: 900x1200 PNG thumbnailed to a 512-long-edge JPEG and served;
unit tests assert exact dimensions, aspect preservation, and that non-images
produce no thumbnail without erroring.
2026-10-09 21:22:30 -04:00

231 lines
6.5 KiB
Go

package library
import (
"database/sql"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/httpx"
)
// Handlers serves library management and scanning endpoints.
type Handlers struct {
db *sql.DB
log *slog.Logger
auth *auth.Authenticator
}
// NewHandlers builds the library 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.HandleFunc("GET /api/v1/media-types", h.handleMediaTypes)
mux.Handle("GET /api/v1/libraries", h.auth.Require(h.handleList))
mux.Handle("GET /api/v1/libraries/{id}", h.auth.Require(h.handleGet))
mux.Handle("GET /api/v1/libraries/{id}/items", h.auth.Require(h.handleListItems))
mux.Handle("POST /api/v1/libraries", h.auth.RequireAdmin(http.HandlerFunc(h.handleCreate)))
mux.Handle("DELETE /api/v1/libraries/{id}", h.auth.RequireAdmin(http.HandlerFunc(h.handleDelete)))
mux.Handle("POST /api/v1/libraries/{id}/scan", h.auth.RequireAdmin(http.HandlerFunc(h.handleScan)))
}
// handleMediaTypes is public: the UI needs the list before login to render.
func (h *Handlers) handleMediaTypes(w http.ResponseWriter, r *http.Request) {
httpx.WriteJSON(w, http.StatusOK, map[string]any{"media_types": SupportedTypes()})
}
func (h *Handlers) handleList(w http.ResponseWriter, r *http.Request, u auth.User) {
ids, err := auth.VisibleLibraryIDs(r.Context(), h.db, u)
if err != nil {
h.fail(w, "visible libraries", err)
return
}
libs, err := ListVisible(r.Context(), h.db, ids)
if err != nil {
h.fail(w, "list libraries", err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"libraries": libs})
}
func (h *Handlers) handleGet(w http.ResponseWriter, r *http.Request, u auth.User) {
id, ok := pathInt(w, r, "id")
if !ok {
return
}
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, id)
if err != nil {
h.fail(w, "check access", err)
return
}
if !allowed {
// 404 rather than 403: an unauthorised user should not learn that a
// library exists.
httpx.WriteError(w, http.StatusNotFound, "library not found")
return
}
lib, err := Get(r.Context(), h.db, id)
if errors.Is(err, ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "library not found")
return
}
if err != nil {
h.fail(w, "get library", err)
return
}
count, _ := CountItems(r.Context(), h.db, id)
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"library": lib,
"item_count": count,
"reachable": RootExists(lib),
})
}
func (h *Handlers) handleCreate(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Kind string `json:"kind"`
RootPath string `json:"root_path"`
}
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
lib, err := Create(r.Context(), h.db, req.Name, req.Kind, req.RootPath)
switch {
case errors.Is(err, ErrNameExists):
httpx.WriteError(w, http.StatusConflict, "a library with that name already exists")
return
case errors.Is(err, ErrInvalidRoot), errors.Is(err, ErrUnknownKind):
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
case err != nil:
h.fail(w, "create library", err)
return
}
h.log.Info("library created", "name", lib.Name, "kind", lib.Kind, "path", lib.RootPath)
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"library": lib})
}
func (h *Handlers) handleDelete(w http.ResponseWriter, r *http.Request) {
id, ok := pathInt(w, r, "id")
if !ok {
return
}
if err := Delete(r.Context(), h.db, id); errors.Is(err, ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "library not found")
return
} else if err != nil {
h.fail(w, "delete library", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handlers) handleScan(w http.ResponseWriter, r *http.Request) {
id, ok := pathInt(w, r, "id")
if !ok {
return
}
lib, err := Get(r.Context(), h.db, id)
if errors.Is(err, ErrNotFound) {
httpx.WriteError(w, http.StatusNotFound, "library not found")
return
}
if err != nil {
h.fail(w, "get library", err)
return
}
res, err := Scan(r.Context(), h.db, lib, h.log)
if err != nil {
h.fail(w, "scan library", err)
return
}
httpx.WriteJSON(w, http.StatusOK, res)
}
// handleListItems returns items in a library the caller may read.
func (h *Handlers) handleListItems(w http.ResponseWriter, r *http.Request, u auth.User) {
id, ok := pathInt(w, r, "id")
if !ok {
return
}
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, id)
if err != nil {
h.fail(w, "check access", err)
return
}
if !allowed {
httpx.WriteError(w, http.StatusNotFound, "library not found")
return
}
limit := 100
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 {
limit = n
}
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT id, library_id, kind, path, title, COALESCE(year, 0), added_at
FROM media_items
WHERE library_id = ? AND missing = 0
ORDER BY sort_title
LIMIT ?`, id, limit)
if err != nil {
h.fail(w, "list items", err)
return
}
defer rows.Close()
type itemDTO struct {
ID int64 `json:"id"`
LibraryID int64 `json:"library_id"`
Kind string `json:"kind"`
Path string `json:"path"`
Title string `json:"title"`
Year int `json:"year"`
AddedAt string `json:"added_at"`
}
items := []itemDTO{}
for rows.Next() {
var it itemDTO
if err := rows.Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, &it.Year, &it.AddedAt); err != nil {
h.fail(w, "scan items", err)
return
}
items = append(items, it)
}
if err := rows.Err(); err != nil {
h.fail(w, "iterate items", err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": items, "limit": limit})
}
// ── helpers ────────────────────────────────────────────────────────────
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")
}
func pathInt(w http.ResponseWriter, r *http.Request, name string) (int64, bool) {
n, err := strconv.ParseInt(r.PathValue(name), 10, 64)
if err != nil || n <= 0 {
httpx.WriteError(w, http.StatusBadRequest, "invalid "+name)
return 0, false
}
return n, true
}