c265b871c3
Persists the operator's metadata-profile choice alongside quality profile + root folder. Defaults to the first profile Lidarr returns (usually 'Standard' on a vanilla install) so the common case is zero-click; operators with custom profiles like 'Singles only' can pick explicitly. Backend: - Migration 0013: adds nullable default_metadata_profile_id to lidarr_config. Existing rows get NULL and the service falls back to fetch-and-pick-first until they save. - Updated lidarr_config queries + sqlc + lidarrconfig.Config + admin view/put body to round-trip the new field. - handlePutLidarrConfig requires it (along with QP and root folder) when enabled=true — matches the existing missing_defaults gate. - New GET /api/admin/lidarr/metadata-profiles handler + lidarr.Client ListMetadataProfiles (GET /api/v1/metadataprofile, same shape as the quality-profile endpoint). - lidarrrequests.Approve prefers cfg.DefaultMetadataProfileID; falls back to the fetch-list path only when 0 (back-compat for upgraders). Frontend: - LidarrConfig type + LidarrMetadataProfile type + qk.lidarrMetadataProfiles. - listMetadataProfiles + createMetadataProfilesQuery client helpers. - Integrations page: third <select> picker, auto-defaults to first profile when the saved value is 0, sends the new field on save and clears it on disconnect. - Updated test fixtures + the duplicate 'Standard' option string in the dropdown-populates assertion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
298 lines
10 KiB
Go
298 lines
10 KiB
Go
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":"<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})
|
|
}
|