Files
minstrel/internal/api/admin_lidarr.go
T
bvandeusen 0bc17a85fb feat(server/admin): test endpoint returns profiles + folders on success
Extends POST /api/admin/lidarr/test response to include quality_profiles,
metadata_profiles, root_folders, and an optional list_errors map when the
Lidarr connection succeeds. The three list fetches run in parallel after
the ping; any per-list failure goes into list_errors so the response can
still report ok=true with whatever data did come back. Failed-ping
response shape is unchanged.

This lets /admin/integrations populate its dropdowns on a single
round-trip during first-time setup, fixing the chicken-and-egg where
the dropdowns previously gated on cfg.Enabled (which can't be true
until the first save, which itself needs non-zero defaults).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:07:15 -04:00

393 lines
13 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"sync"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
// lidarrConfigView is the JSON shape returned by GET /api/admin/lidarr/config
// and PUT /api/admin/lidarr/config. api_key is always masked as "***" when set.
type lidarrConfigView struct {
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
DefaultQualityProfileID int `json:"default_quality_profile_id"`
DefaultMetadataProfileID int `json:"default_metadata_profile_id"`
DefaultRootFolderPath string `json:"default_root_folder_path"`
}
// maskAPIKey converts an api_key for external response: non-empty keys become
// "***"; empty (never set) keys remain "".
func maskAPIKey(key string) string {
if key == "" {
return ""
}
return "***"
}
// configToView converts a lidarrconfig.Config to a lidarrConfigView with the
// api_key masked.
func configToView(cfg lidarrconfig.Config) lidarrConfigView {
return lidarrConfigView{
Enabled: cfg.Enabled,
BaseURL: cfg.BaseURL,
APIKey: maskAPIKey(cfg.APIKey),
DefaultQualityProfileID: cfg.DefaultQualityProfileID,
DefaultMetadataProfileID: cfg.DefaultMetadataProfileID,
DefaultRootFolderPath: cfg.DefaultRootFolderPath,
}
}
// handleGetLidarrConfig implements GET /api/admin/lidarr/config.
// Returns the current Lidarr config with api_key masked.
func (h *handlers) handleGetLidarrConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := h.lidarrCfg.Get(r.Context())
if err != nil {
h.logger.Error("admin: get lidarr config", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
return
}
writeJSON(w, http.StatusOK, configToView(cfg))
}
// putLidarrConfigBody is the decoded JSON body for PUT /api/admin/lidarr/config.
type putLidarrConfigBody struct {
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
DefaultQualityProfileID int `json:"default_quality_profile_id"`
DefaultMetadataProfileID int `json:"default_metadata_profile_id"`
DefaultRootFolderPath string `json:"default_root_folder_path"`
}
// handlePutLidarrConfig implements PUT /api/admin/lidarr/config.
// Empty api_key in the body preserves the currently saved api_key.
func (h *handlers) handlePutLidarrConfig(w http.ResponseWriter, r *http.Request) {
var body putLidarrConfigBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeAdminJSONErr(w, http.StatusBadRequest, "bad_request")
return
}
// Resolve the effective api_key: empty in body = preserve saved value.
apiKey := body.APIKey
if apiKey == "" {
saved, err := h.lidarrCfg.Get(r.Context())
if err != nil {
h.logger.Error("admin: put lidarr config: load saved", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
return
}
apiKey = saved.APIKey
}
// Validate: enabled=true requires base_url AND api_key.
if body.Enabled {
if body.BaseURL == "" || apiKey == "" {
writeAdminJSONErr(w, http.StatusBadRequest, "missing_required_field")
return
}
// Validate that the URL parses.
if _, err := url.ParseRequestURI(body.BaseURL); err != nil {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_url")
return
}
// Defaults must be present so future Approve calls don't dispatch
// invalid POST bodies to Lidarr (which produces 4xx with field
// errors). The frontend pre-selects first values so this almost
// never fires for non-malicious traffic, but it's the right gate.
if body.DefaultQualityProfileID == 0 ||
body.DefaultMetadataProfileID == 0 ||
body.DefaultRootFolderPath == "" {
writeAdminJSONErr(w, http.StatusBadRequest, "missing_defaults")
return
}
}
cfg := lidarrconfig.Config{
Enabled: body.Enabled,
BaseURL: body.BaseURL,
APIKey: apiKey,
DefaultQualityProfileID: body.DefaultQualityProfileID,
DefaultMetadataProfileID: body.DefaultMetadataProfileID,
DefaultRootFolderPath: body.DefaultRootFolderPath,
}
if err := h.lidarrCfg.Save(r.Context(), cfg); err != nil {
h.logger.Error("admin: put lidarr config: save", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
return
}
writeJSON(w, http.StatusOK, configToView(cfg))
}
// testLidarrBody is the optional JSON body for POST /api/admin/lidarr/test.
type testLidarrBody struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
}
// testLidarrResponse is the JSON shape returned by POST /api/admin/lidarr/test.
// All fields except Ok are optional. On a failed connection (Ok=false), only
// Ok and Error are populated. On success (Ok=true), Version is populated;
// QualityProfiles / MetadataProfiles / RootFolders carry the live Lidarr
// data and ListErrors maps any per-list fetch failures by list name. The
// integrations page consumes the lists to pre-fill its dropdowns on
// first-time setup, fixing the chicken-and-egg where the dropdowns
// previously gated on cfg.Enabled.
type testLidarrResponse struct {
Ok bool `json:"ok"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
QualityProfiles []qualityProfileView `json:"quality_profiles,omitempty"`
MetadataProfiles []metadataProfileView `json:"metadata_profiles,omitempty"`
RootFolders []rootFolderView `json:"root_folders,omitempty"`
ListErrors map[string]string `json:"list_errors,omitempty"`
}
// handleTestLidarrConnection implements POST /api/admin/lidarr/test.
// Always returns 200; the ok/error fields in the response body indicate
// connection success or failure.
func (h *handlers) handleTestLidarrConnection(w http.ResponseWriter, r *http.Request) {
var body testLidarrBody
// Ignore decode errors — an empty body is valid (all fields optional).
_ = json.NewDecoder(r.Body).Decode(&body)
// Fall back to saved config for any empty field.
baseURL := body.BaseURL
apiKey := body.APIKey
if baseURL == "" || apiKey == "" {
saved, err := h.lidarrCfg.Get(r.Context())
if err != nil {
h.logger.Error("admin: test lidarr: load config", "err", err)
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "internal_error"})
return
}
if baseURL == "" {
baseURL = saved.BaseURL
}
if apiKey == "" {
apiKey = saved.APIKey
}
}
client := lidarr.NewClient(baseURL, apiKey)
result, err := client.Ping(r.Context())
if err != nil {
errCode := lidarrErrCode(err)
// Warn (not Error): a failed connection test is expected when the
// admin is configuring the integration and may have a typo'd URL
// or wrong key. Without this log line the underlying *net.DNSError
// / *net.OpError / TLS error is invisible to operators — they only
// see the bucket code in the UI. base_url is safe to log; api_key
// must NEVER be logged.
h.logger.Warn("admin: test lidarr ping failed",
"err", err, "base_url", baseURL, "code", errCode)
writeJSON(w, http.StatusOK, testLidarrResponse{Ok: false, Error: errCode})
return
}
quality, metadata, folders, listErrs := fetchLidarrLists(r.Context(), client)
writeJSON(w, http.StatusOK, testLidarrResponse{
Ok: true,
Version: result.Version,
QualityProfiles: quality,
MetadataProfiles: metadata,
RootFolders: folders,
ListErrors: listErrs,
})
}
// fetchLidarrLists fans out the three list calls in parallel and returns
// the lists plus a map of any per-list errors. Empty map (returned as nil
// to omit the JSON field) if all succeeded. Used by the test handler to
// pre-populate the integrations page dropdowns on the same round-trip as
// the connection check.
func fetchLidarrLists(ctx context.Context, client *lidarr.Client) (
[]qualityProfileView, []metadataProfileView, []rootFolderView, map[string]string,
) {
var (
quality []qualityProfileView
metadata []metadataProfileView
folders []rootFolderView
errMu sync.Mutex
errs = map[string]string{}
wg sync.WaitGroup
)
recordErr := func(name string, err error) {
errMu.Lock()
errs[name] = lidarrErrCode(err)
errMu.Unlock()
}
wg.Add(3)
go func() {
defer wg.Done()
ps, err := client.ListQualityProfiles(ctx)
if err != nil {
recordErr("quality_profiles", err)
return
}
quality = make([]qualityProfileView, len(ps))
for i, p := range ps {
quality[i] = qualityProfileView{ID: p.ID, Name: p.Name}
}
}()
go func() {
defer wg.Done()
ps, err := client.ListMetadataProfiles(ctx)
if err != nil {
recordErr("metadata_profiles", err)
return
}
metadata = make([]metadataProfileView, len(ps))
for i, p := range ps {
metadata[i] = metadataProfileView{ID: p.ID, Name: p.Name}
}
}()
go func() {
defer wg.Done()
fs, err := client.ListRootFolders(ctx)
if err != nil {
recordErr("root_folders", err)
return
}
folders = make([]rootFolderView, len(fs))
for i, f := range fs {
folders[i] = rootFolderView{Path: f.Path, Accessible: f.Accessible, FreeSpace: f.FreeSpace}
}
}()
wg.Wait()
if len(errs) == 0 {
return quality, metadata, folders, nil
}
return quality, metadata, folders, errs
}
// lidarrErrCode maps a lidarr client error to the stable string code used in
// admin API responses.
func lidarrErrCode(err error) string {
switch {
case errors.Is(err, lidarr.ErrUnreachable):
return "lidarr_unreachable"
case errors.Is(err, lidarr.ErrAuthFailed):
return "lidarr_auth_failed"
case errors.Is(err, lidarr.ErrLookupFailed):
return "lidarr_lookup_failed"
default:
return "internal_error"
}
}
// qualityProfileView is the JSON shape for a single quality profile in the
// GET /api/admin/lidarr/quality-profiles response.
type qualityProfileView struct {
ID int `json:"id"`
Name string `json:"name"`
}
// handleListQualityProfiles implements GET /api/admin/lidarr/quality-profiles.
func (h *handlers) handleListQualityProfiles(w http.ResponseWriter, r *http.Request) {
_, client, ok := h.lidarrClientFromConfig(w, r)
if !ok {
return
}
profiles, err := client.ListQualityProfiles(r.Context())
if err != nil {
writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err))
return
}
out := make([]qualityProfileView, len(profiles))
for i, p := range profiles {
out[i] = qualityProfileView{ID: p.ID, Name: p.Name}
}
writeJSON(w, http.StatusOK, out)
}
// metadataProfileView is the JSON shape for a single metadata profile in the
// GET /api/admin/lidarr/metadata-profiles response.
type metadataProfileView struct {
ID int `json:"id"`
Name string `json:"name"`
}
// handleListMetadataProfiles implements GET /api/admin/lidarr/metadata-profiles.
func (h *handlers) handleListMetadataProfiles(w http.ResponseWriter, r *http.Request) {
_, client, ok := h.lidarrClientFromConfig(w, r)
if !ok {
return
}
profiles, err := client.ListMetadataProfiles(r.Context())
if err != nil {
writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err))
return
}
out := make([]metadataProfileView, len(profiles))
for i, p := range profiles {
out[i] = metadataProfileView{ID: p.ID, Name: p.Name}
}
writeJSON(w, http.StatusOK, out)
}
// rootFolderView is the JSON shape for a single root folder in the
// GET /api/admin/lidarr/root-folders response.
type rootFolderView struct {
Path string `json:"path"`
Accessible bool `json:"accessible"`
FreeSpace int64 `json:"free_space"`
}
// handleListRootFolders implements GET /api/admin/lidarr/root-folders.
func (h *handlers) handleListRootFolders(w http.ResponseWriter, r *http.Request) {
_, client, ok := h.lidarrClientFromConfig(w, r)
if !ok {
return
}
folders, err := client.ListRootFolders(r.Context())
if err != nil {
writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err))
return
}
out := make([]rootFolderView, len(folders))
for i, f := range folders {
out[i] = rootFolderView{Path: f.Path, Accessible: f.Accessible, FreeSpace: f.FreeSpace}
}
writeJSON(w, http.StatusOK, out)
}
// lidarrClientFromConfig is a shared helper for handlers that need to proxy a
// request to Lidarr. It loads the saved config, returns 503 when disabled, and
// constructs a Client. Returns (cfg, client, true) on success; (_, nil, false)
// when a response has already been written.
func (h *handlers) lidarrClientFromConfig(w http.ResponseWriter, r *http.Request) (lidarrconfig.Config, *lidarr.Client, bool) {
cfg, err := h.lidarrCfg.Get(r.Context())
if err != nil {
h.logger.Error("admin: lidarr proxy: load config", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
return lidarrconfig.Config{}, nil, false
}
if !cfg.Enabled {
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled")
return lidarrconfig.Config{}, nil, false
}
return cfg, lidarr.NewClient(cfg.BaseURL, cfg.APIKey), true
}
// writeAdminJSONErr writes a flat {"error":"<code>"} JSON response. This
// mirrors the shape the RequireAdmin middleware uses and that the spec
// defines for /api/admin/* errors.
func writeAdminJSONErr(w http.ResponseWriter, status int, code string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": code})
}