Five tasks, four delegated to agents on disjoint files and one on the critical
path. Board and verification log: ~/.opencode/plan/vellum-beta-tasks.md
T1 Async scans (coordinator)
- migration 0007 adds ref_id + result to jobs, indexed by (kind, ref_id, id),
so a job can be found by what it operates on and can report back.
- jobs: EnqueueRef, JobID(ctx) so a handler can attach a result, SetResult.
- POST /libraries/{id}/scan now enqueues and returns 202; a real library takes
longer than any request timeout, and a hanging request looks like a broken
server. GET /libraries/{id}/scan/status reports the latest run.
- Libraries.svelte polls and shows "Scanning…" instead of freezing.
T2 Admin CLI (agent)
- internal/cli: `user add|passwd|list`, `backup <path>` via VACUUM INTO, with
tests. Wired into main.go, which the coordinator kept.
T3 Deploy (agent)
- compose.yml now mounts all six media kinds read-only with :z and carries the
metadata env keys. DEPLOY.md covers volumes, SELinux, admin bootstrap, TLS,
backup and upgrade.
T4 Pagination (agent)
- GET /items takes offset and returns total; Browse loads 60 at a time with
"Load more" and "Showing X of Y" instead of silently truncating at 300.
T5 Password change UI (agent)
- A Password section in Settings with validation, busy and success states.
Verified end to end: scan returns 202 in 0.7 ms and polls to done; the CLI
created a user, changed a password, and produced a complete 240 KB backup while
the server ran; pagination reports total 8 and pages correctly; compose parses
with every mount :z. go vet and 14 test packages pass; svelte-check 0/0.
279 lines
8 KiB
Go
279 lines
8 KiB
Go
package library
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/mithral/vellum/internal/auth"
|
|
"github.com/mithral/vellum/internal/httpx"
|
|
"github.com/mithral/vellum/internal/jobs"
|
|
)
|
|
|
|
// ScanJobKind is the queue job that scans a library.
|
|
const ScanJobKind = "scan_library"
|
|
|
|
// ScanPayload is the job payload for a scan.
|
|
type ScanPayload struct {
|
|
LibraryID int64 `json:"library_id"`
|
|
}
|
|
|
|
// Handlers serves library management and scanning endpoints.
|
|
type Handlers struct {
|
|
db *sql.DB
|
|
log *slog.Logger
|
|
auth *auth.Authenticator
|
|
queue *jobs.Queue
|
|
}
|
|
|
|
// NewHandlers builds the library HTTP layer.
|
|
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator, q *jobs.Queue) *Handlers {
|
|
return &Handlers{db: db, log: log, auth: a, queue: q}
|
|
}
|
|
|
|
// 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("GET /api/v1/libraries/{id}/scan/status", h.auth.RequireAuth(http.HandlerFunc(h.handleScanStatus)))
|
|
|
|
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
|
|
}
|
|
|
|
// Queued rather than run inline: a real library takes longer than any
|
|
// sensible request timeout, and a hanging request is indistinguishable
|
|
// from a broken server.
|
|
jobID, err := h.queue.EnqueueRef(r.Context(), ScanJobKind, lib.ID, ScanPayload{LibraryID: lib.ID})
|
|
if err != nil {
|
|
h.fail(w, "enqueue scan", err)
|
|
return
|
|
}
|
|
h.log.Info("scan queued", "library", lib.Name, "job", jobID)
|
|
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"queued": 1, "job_id": jobID})
|
|
}
|
|
|
|
// handleScanStatus reports the most recent scan for a library, so the UI can
|
|
// show progress instead of hanging on the request.
|
|
func (h *Handlers) handleScanStatus(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := pathInt(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var state, result, errMsg string
|
|
err := h.db.QueryRowContext(r.Context(), `
|
|
SELECT state, result, error FROM jobs
|
|
WHERE kind = ? AND ref_id = ?
|
|
ORDER BY id DESC LIMIT 1`, ScanJobKind, id).Scan(&state, &result, &errMsg)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"state": "idle"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
h.fail(w, "scan status", err)
|
|
return
|
|
}
|
|
|
|
out := map[string]any{"state": state}
|
|
if result != "" {
|
|
out["result"] = json.RawMessage(result)
|
|
}
|
|
if errMsg != "" {
|
|
out["error"] = errMsg
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// 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
|
|
}
|