Files
minstrel/internal/lidarr/client.go
T
bvandeusen ab8235dd0b fix(admin): approve flow surfaces real errors; auto-default Lidarr picks
The "I can't approve a request, the toast just says unknown" bug was
four problems compounded:

1. Lidarr config let enabled=true save with default_quality_profile_id=0
   and default_root_folder_path=''. Approve then sent invalid POST bodies
   to Lidarr, which 5xx'd.
2. lidarr.client.post() discarded Lidarr's response body on error, so
   we couldn't tell why Lidarr 5xx'd from server logs.
3. handleApproveRequest's error switch didn't map ErrServerError or
   ErrLookupFailed — both fell through to a generic 500 server_error.
4. apiFetch only parsed {error: {code, message}} envelopes, but admin
   endpoints write {error: 'code_string'}. Every admin error toast
   rendered as 'unknown'.

Fixes:

- internal/lidarr/client.go: capture up to 512 bytes of Lidarr's response
  body when it returns 4xx/5xx; include in the wrapped error so server
  logs show what Lidarr actually said instead of just the status bucket.
- internal/lidarrrequests/service.go: new ErrDefaultsIncomplete fires
  before the Lidarr call when QP=0 or root_folder=''. Stops the bad
  POST entirely.
- internal/api/admin_requests.go: handleApproveRequest now maps
  ErrDefaultsIncomplete -> 'lidarr_defaults_incomplete' (400),
  ErrServerError -> 'lidarr_server_error' (502),
  ErrLookupFailed -> 'lidarr_rejected' (502).
- internal/api/admin_lidarr.go: handlePutLidarrConfig now requires
  QP + root folder to be set whenever enabled=true.
- web/src/lib/api/client.ts: apiFetch handles both error envelope shapes
  so admin error codes propagate to toasts.
- web/src/routes/admin/integrations/+page.svelte: auto-default to the
  first quality profile and first root folder Lidarr returns when the
  operator hasn't picked one yet — saves a click for typical
  one-profile/one-folder home setups.
- web/src/routes/admin/requests/+page.svelte: friendly toast copy for
  the new error codes.

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

398 lines
12 KiB
Go

package lidarr
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// Client wraps Lidarr's v1 HTTP API. BaseURL is the Lidarr instance
// (e.g. http://lidarr.lan:8686), APIKey comes from Lidarr's settings.
type Client struct {
BaseURL string
APIKey string
HTTP *http.Client
}
// NewClient builds a Client with sensible HTTP defaults (30s timeout,
// matching the pattern in internal/scrobble/listenbrainz). Call sites
// that need a custom client can construct the Client struct directly.
func NewClient(baseURL, apiKey string) *Client {
return &Client{
BaseURL: baseURL,
APIKey: apiKey,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// get executes an authenticated GET request and maps HTTP status codes to
// typed errors. The caller is responsible for closing the returned body.
func (c *Client) get(ctx context.Context, path string, q url.Values) (*http.Response, error) {
u, err := url.Parse(c.BaseURL)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
}
u.Path = strings.TrimRight(u.Path, "/") + path
if q != nil {
u.RawQuery = q.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Api-Key", c.APIKey)
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
_ = resp.Body.Close()
return nil, ErrAuthFailed
}
if resp.StatusCode >= 500 {
body := readBodySnippet(resp.Body)
_ = resp.Body.Close()
return nil, fmt.Errorf("%w: %d: %s", ErrServerError, resp.StatusCode, body)
}
if resp.StatusCode >= 400 {
body := readBodySnippet(resp.Body)
_ = resp.Body.Close()
return nil, fmt.Errorf("%w: %d: %s", ErrLookupFailed, resp.StatusCode, body)
}
return resp, nil
}
// readBodySnippet reads up to 512 bytes from an error response body so the
// wrapped error message can include Lidarr's actual complaint (e.g.
// `{"errorMessage":"Artist already exists in your library"}`). Without this,
// callers see only the status-code bucket and can't diagnose without
// cross-referencing Lidarr's own logs.
func readBodySnippet(r io.Reader) string {
buf, _ := io.ReadAll(io.LimitReader(r, 512))
return strings.TrimSpace(string(buf))
}
// post is the shared POST helper. body must be marshaled JSON. It maps HTTP
// status codes to typed errors; the caller is responsible for closing the
// returned body.
func (c *Client) post(ctx context.Context, path string, body []byte) (*http.Response, error) {
u, err := url.Parse(c.BaseURL)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
}
u.Path = strings.TrimRight(u.Path, "/") + path
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("X-Api-Key", c.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
_ = resp.Body.Close()
return nil, ErrAuthFailed
}
if resp.StatusCode >= 500 {
body := readBodySnippet(resp.Body)
_ = resp.Body.Close()
return nil, fmt.Errorf("%w: %d: %s", ErrServerError, resp.StatusCode, body)
}
if resp.StatusCode >= 400 {
body := readBodySnippet(resp.Body)
_ = resp.Body.Close()
return nil, fmt.Errorf("%w: %d: %s", ErrLookupFailed, resp.StatusCode, body)
}
return resp, nil
}
// LookupArtist hits GET /api/v1/artist/lookup?term=. Returns normalized
// LookupResults; Secondary is "{first genre} · {albumCount} albums" with
// missing parts skipped.
func (c *Client) LookupArtist(ctx context.Context, term string) ([]LookupResult, error) {
resp, err := c.get(ctx, "/api/v1/artist/lookup", url.Values{"term": {term}})
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
var raw []struct {
ForeignArtistID string `json:"foreignArtistId"`
ArtistName string `json:"artistName"`
Genres []string `json:"genres"`
AlbumCount int `json:"albumCount"`
Images []lidarrImage `json:"images"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
out := make([]LookupResult, 0, len(raw))
for _, r := range raw {
secondary := ""
if len(r.Genres) > 0 {
secondary = r.Genres[0]
}
if r.AlbumCount > 0 {
if secondary != "" {
secondary += " · "
}
secondary += strconv.Itoa(r.AlbumCount) + " albums"
}
out = append(out, LookupResult{
MBID: r.ForeignArtistID,
Name: r.ArtistName,
Secondary: secondary,
ImageURL: pickPosterImage(r.Images),
})
}
return out, nil
}
// LookupAlbum hits GET /api/v1/album/lookup?term=. Returns normalized
// LookupResults; Secondary is "{artistName} · {year} · {trackCount} tracks".
func (c *Client) LookupAlbum(ctx context.Context, term string) ([]LookupResult, error) {
resp, err := c.get(ctx, "/api/v1/album/lookup", url.Values{"term": {term}})
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
var raw []struct {
ForeignAlbumID string `json:"foreignAlbumId"`
ForeignArtistID string `json:"foreignArtistId"`
Title string `json:"title"`
ArtistName string `json:"artistName"`
ReleaseDate string `json:"releaseDate"`
TrackCount int `json:"trackCount"`
Images []lidarrImage `json:"images"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
out := make([]LookupResult, 0, len(raw))
for _, r := range raw {
year := ""
if len(r.ReleaseDate) >= 4 {
year = r.ReleaseDate[:4]
}
secondary := ""
if r.ArtistName != "" {
secondary = r.ArtistName
}
if year != "" {
if secondary != "" {
secondary += " · "
}
secondary += year
}
if r.TrackCount > 0 {
if secondary != "" {
secondary += " · "
}
secondary += strconv.Itoa(r.TrackCount) + " tracks"
}
out = append(out, LookupResult{
MBID: r.ForeignAlbumID,
ArtistMBID: r.ForeignArtistID,
Name: r.Title,
Secondary: secondary,
ImageURL: pickPosterImage(r.Images),
})
}
return out, nil
}
// LookupTrack hits GET /api/v1/track/lookup?term=. Lidarr's track lookup is
// per-album under the hood. Secondary is "{albumTitle} · {artistName}".
func (c *Client) LookupTrack(ctx context.Context, term string) ([]LookupResult, error) {
resp, err := c.get(ctx, "/api/v1/track/lookup", url.Values{"term": {term}})
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
var raw []struct {
ForeignTrackID string `json:"foreignTrackId"`
ForeignAlbumID string `json:"foreignAlbumId"`
ForeignArtistID string `json:"foreignArtistId"`
Title string `json:"title"`
AlbumTitle string `json:"albumTitle"`
ArtistName string `json:"artistName"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
out := make([]LookupResult, 0, len(raw))
for _, r := range raw {
secondary := r.AlbumTitle
if r.ArtistName != "" {
if secondary != "" {
secondary += " · "
}
secondary += r.ArtistName
}
out = append(out, LookupResult{
MBID: r.ForeignTrackID,
ArtistMBID: r.ForeignArtistID,
AlbumMBID: r.ForeignAlbumID,
Name: r.Title,
Secondary: secondary,
})
}
return out, nil
}
// AddArtist posts to POST /api/v1/artist. MonitorAll=true sends monitor="all";
// false sends "future". Returns nil on 2xx; typed error otherwise.
func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error {
monitor := "future"
if p.MonitorAll {
monitor = "all"
}
body, err := json.Marshal(map[string]any{
"foreignArtistId": p.ForeignArtistID,
"qualityProfileId": p.QualityProfileID,
"rootFolderPath": p.RootFolderPath,
"monitored": true,
"monitor": monitor,
"addOptions": map[string]any{"searchForMissingAlbums": true},
})
if err != nil {
return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err)
}
resp, err := c.post(ctx, "/api/v1/artist", body)
if err != nil {
return err
}
_ = resp.Body.Close()
return nil
}
// AddAlbum posts to POST /api/v1/album. Returns nil on 2xx; typed error
// otherwise.
func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error {
body, err := json.Marshal(map[string]any{
"foreignAlbumId": p.ForeignAlbumID,
"foreignArtistId": p.ForeignArtistID,
"qualityProfileId": p.QualityProfileID,
"rootFolderPath": p.RootFolderPath,
"monitored": true,
"addOptions": map[string]any{"searchForNewAlbum": true},
})
if err != nil {
return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err)
}
resp, err := c.post(ctx, "/api/v1/album", body)
if err != nil {
return err
}
_ = resp.Body.Close()
return nil
}
// ListQualityProfiles hits GET /api/v1/qualityprofile and returns the full
// list of quality profiles configured in Lidarr.
func (c *Client) ListQualityProfiles(ctx context.Context) ([]QualityProfile, error) {
resp, err := c.get(ctx, "/api/v1/qualityprofile", nil)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
var raw []struct {
ID int `json:"id"`
Name string `json:"name"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
out := make([]QualityProfile, len(raw))
for i, r := range raw {
out[i] = QualityProfile{ID: r.ID, Name: r.Name}
}
return out, nil
}
// ListRootFolders hits GET /api/v1/rootfolder and returns the list of root
// folders configured in Lidarr.
func (c *Client) ListRootFolders(ctx context.Context) ([]RootFolder, error) {
resp, err := c.get(ctx, "/api/v1/rootfolder", nil)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
var raw []struct {
Path string `json:"path"`
Accessible bool `json:"accessible"`
FreeSpace int64 `json:"freeSpace"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
out := make([]RootFolder, len(raw))
for i, r := range raw {
out[i] = RootFolder{Path: r.Path, Accessible: r.Accessible, FreeSpace: r.FreeSpace}
}
return out, nil
}
// Ping hits GET /api/v1/system/status to verify connectivity and returns
// the Lidarr version string.
func (c *Client) Ping(ctx context.Context) (PingResult, error) {
resp, err := c.get(ctx, "/api/v1/system/status", nil)
if err != nil {
return PingResult{}, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
var raw struct {
Version string `json:"version"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
}
return PingResult{Version: raw.Version}, nil
}
// pickPosterImage returns the remote URL (preferred) or local URL for the
// first image with coverType=="poster". Returns "" when none match.
func pickPosterImage(imgs []lidarrImage) string {
for _, img := range imgs {
if img.CoverType == "poster" {
if img.RemoteURL != "" {
return img.RemoteURL
}
return img.URL
}
}
return ""
}