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) { return c.bodyRequest(ctx, http.MethodPost, path, body) } // put is the shared PUT helper. Body must be marshaled JSON. Mirrors post() — // same status-code mapping, same caller-closes-body contract. func (c *Client) put(ctx context.Context, path string, body []byte) (*http.Response, error) { return c.bodyRequest(ctx, http.MethodPut, path, body) } // bodyRequest is the shared core for POST and PUT. Lidarr's monitor toggle // is PUT-shaped, so factoring it out keeps the post/put helpers from // duplicating the status-code mapping. func (c *Client) bodyRequest(ctx context.Context, method, 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, method, 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. // // All four of artistName, foreignArtistId, qualityProfileId, and // metadataProfileId are required by Lidarr. Omitting any one produces a // 400 with field-level validation messages (e.g. "'Metadata Profile Id' // must be greater than '0'"). 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, "artistName": p.ArtistName, "qualityProfileId": p.QualityProfileID, "metadataProfileId": p.MetadataProfileID, "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, "artistName": p.ArtistName, "qualityProfileId": p.QualityProfileID, "metadataProfileId": p.MetadataProfileID, "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 } // ListMetadataProfiles hits GET /api/v1/metadataprofile and returns the // list of metadata profiles configured in Lidarr. The Approve flow uses // the first profile as a default when the operator hasn't pinned one. func (c *Client) ListMetadataProfiles(ctx context.Context) ([]MetadataProfile, error) { resp, err := c.get(ctx, "/api/v1/metadataprofile", 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([]MetadataProfile, len(raw)) for i, r := range raw { out[i] = MetadataProfile{ID: r.ID, Name: r.Name} } return out, 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 "" }