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 }