- library: CRUD with root-path resolution (absolute, symlink-free, must be readable). A library name is unique; the root is validated at creation. - registry: media types declare their extensions; the core never imports a module. RegisterType is the extension point. - scanner: walks a root, reconciles media_items/media_files in one transaction, skips unchanged files by size+mtime, and marks vanished files missing rather than deleting them (progress is the one thing here that cannot be regenerated). A single unreadable file never fails a scan. - migration 0002: `missing` flags on items and files, plus indexes. - endpoints: media-types, library list/get/create/delete, per-library items, and scan. Library reads go through the same grant chokepoint as everything else; an unauthorised library 404s rather than 403s so its existence is not leaked.
230 lines
6.4 KiB
Go
230 lines
6.4 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, 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"`
|
|
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.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
|
|
}
|