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":""} 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}) }