package api import ( "encoding/json" "errors" "net/http" "net/url" "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"` } // 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, map[string]any{"ok": false, "error": errCode}) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": result.Version}) } // 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":""} 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}) }