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("GET /api/v1/missing", h.auth.Require(h.handleMissing)) 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))) mux.Handle("POST /api/v1/libraries/{id}/purge", h.auth.RequireAdmin(http.HandlerFunc(h.handlePurge))) } // 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}) } // handleMissing lists the files the scanner has flagged missing in the // libraries the caller may read. An optional ?library_id= narrows the result // to a single library, and an id outside the caller's visible set simply // yields nothing rather than leaking that the library exists. func (h *Handlers) handleMissing(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 } if v := r.URL.Query().Get("library_id"); v != "" { id, err := strconv.ParseInt(v, 10, 64) if err != nil || id <= 0 { httpx.WriteError(w, http.StatusBadRequest, "invalid library_id") return } ids = narrowToLibrary(ids, id) } entries, err := ListMissing(r.Context(), h.db, ids) if err != nil { h.fail(w, "list missing", err) return } httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": entries, "total": len(entries)}) } // handlePurge deletes a library's genuinely missing items on explicit admin // request. It is admin-only and never runs automatically: the delete cascades // into progress, which cannot be regenerated. See PurgeMissing for the full // cascade and the exact "actually missing" condition. func (h *Handlers) handlePurge(w http.ResponseWriter, r *http.Request) { id, ok := pathInt(w, r, "id") if !ok { return } n, err := PurgeMissing(r.Context(), h.db, id) if err != nil { h.fail(w, "purge missing", err) return } h.log.Info("purged missing items", "library", id, "count", n) httpx.WriteJSON(w, http.StatusOK, map[string]any{"purged": n}) } // ── helpers ──────────────────────────────────────────────────────────── // narrowToLibrary keeps only id when the caller asks for one library. An id // outside the visible set yields an empty set, so a non-admin cannot probe a // library they cannot read. func narrowToLibrary(ids []int64, id int64) []int64 { for _, v := range ids { if v == id { return []int64{id} } } return []int64{} } 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 }