feat(lidarr): typed HTTP client for v1 API (lookup, add, profiles, ping)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
package lidarr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 = 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 {
|
||||
_ = resp.Body.Close()
|
||||
return nil, ErrServerError
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
_ = resp.Body.Close()
|
||||
return nil, ErrLookupFailed
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 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 = 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 {
|
||||
_ = resp.Body.Close()
|
||||
return nil, ErrServerError
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
_ = resp.Body.Close()
|
||||
return nil, ErrLookupFailed
|
||||
}
|
||||
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 []struct {
|
||||
CoverType string `json:"coverType"`
|
||||
RemoteURL string `json:"remoteUrl"`
|
||||
URL string `json:"url"`
|
||||
} `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"`
|
||||
Title string `json:"title"`
|
||||
ArtistName string `json:"artistName"`
|
||||
ReleaseDate string `json:"releaseDate"`
|
||||
TrackCount int `json:"trackCount"`
|
||||
Images []struct {
|
||||
CoverType string `json:"coverType"`
|
||||
RemoteURL string `json:"remoteUrl"`
|
||||
URL string `json:"url"`
|
||||
} `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 := r.ArtistName
|
||||
if year != "" {
|
||||
secondary += " · " + year
|
||||
}
|
||||
if r.TrackCount > 0 {
|
||||
secondary += " · " + strconv.Itoa(r.TrackCount) + " tracks"
|
||||
}
|
||||
out = append(out, LookupResult{
|
||||
MBID: r.ForeignAlbumID,
|
||||
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"`
|
||||
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,
|
||||
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, _ := json.Marshal(map[string]any{
|
||||
"foreignArtistId": p.ForeignArtistID,
|
||||
"qualityProfileId": p.QualityProfileID,
|
||||
"rootFolderPath": p.RootFolderPath,
|
||||
"monitored": true,
|
||||
"monitor": monitor,
|
||||
"addOptions": map[string]any{"searchForMissingAlbums": true},
|
||||
})
|
||||
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, _ := 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},
|
||||
})
|
||||
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 []struct {
|
||||
CoverType string `json:"coverType"`
|
||||
RemoteURL string `json:"remoteUrl"`
|
||||
URL string `json:"url"`
|
||||
}) string {
|
||||
for _, img := range imgs {
|
||||
if img.CoverType == "poster" {
|
||||
if img.RemoteURL != "" {
|
||||
return img.RemoteURL
|
||||
}
|
||||
return img.URL
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
package lidarr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newTestClient creates an httptest.Server and a Client wired to it.
|
||||
func newTestClient(handler http.HandlerFunc) (*Client, *httptest.Server) {
|
||||
srv := httptest.NewServer(handler)
|
||||
return &Client{BaseURL: srv.URL, APIKey: "key123", HTTP: srv.Client()}, srv
|
||||
}
|
||||
|
||||
// mustReadFixture reads a file from testdata/ or fatals the test.
|
||||
func mustReadFixture(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile("testdata/" + name)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture %q: %v", name, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// --- LookupArtist ---
|
||||
|
||||
func TestLookupArtist_HappyPath(t *testing.T) {
|
||||
body := mustReadFixture(t, "lookup_artist.json")
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-Api-Key"); got != "key123" {
|
||||
t.Errorf("X-Api-Key = %q, want key123", got)
|
||||
}
|
||||
if r.URL.Path != "/api/v1/artist/lookup" {
|
||||
t.Errorf("path = %q, want /api/v1/artist/lookup", r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("term"); got != "boards" {
|
||||
t.Errorf("term = %q, want boards", got)
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
got, err := c.LookupArtist(context.Background(), "boards")
|
||||
if err != nil {
|
||||
t.Fatalf("LookupArtist: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
// First result — rich data.
|
||||
if got[0].MBID != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" {
|
||||
t.Errorf("MBID = %q", got[0].MBID)
|
||||
}
|
||||
if got[0].Name != "Boards of Canada" {
|
||||
t.Errorf("Name = %q", got[0].Name)
|
||||
}
|
||||
if got[0].Secondary != "Electronic · 18 albums" {
|
||||
t.Errorf("Secondary = %q, want %q", got[0].Secondary, "Electronic · 18 albums")
|
||||
}
|
||||
if got[0].ImageURL != "https://example.invalid/boc.jpg" {
|
||||
t.Errorf("ImageURL = %q", got[0].ImageURL)
|
||||
}
|
||||
|
||||
// Second result — empty genres and zero album count → empty secondary.
|
||||
if got[1].Secondary != "" {
|
||||
t.Errorf("expected empty secondary for no genres + 0 albums; got %q", got[1].Secondary)
|
||||
}
|
||||
if got[1].ImageURL != "" {
|
||||
t.Errorf("expected empty ImageURL; got %q", got[1].ImageURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupArtist_AuthFailed(t *testing.T) {
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
})
|
||||
defer srv.Close()
|
||||
_, err := c.LookupArtist(context.Background(), "boards")
|
||||
if !errors.Is(err, ErrAuthFailed) {
|
||||
t.Fatalf("err = %v, want ErrAuthFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupArtist_Forbidden(t *testing.T) {
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
})
|
||||
defer srv.Close()
|
||||
_, err := c.LookupArtist(context.Background(), "boards")
|
||||
if !errors.Is(err, ErrAuthFailed) {
|
||||
t.Fatalf("err = %v, want ErrAuthFailed (403 must map to auth)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupArtist_ServerError(t *testing.T) {
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
defer srv.Close()
|
||||
_, err := c.LookupArtist(context.Background(), "boards")
|
||||
if !errors.Is(err, ErrServerError) {
|
||||
t.Fatalf("err = %v, want ErrServerError", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupArtist_Unreachable(t *testing.T) {
|
||||
c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "x", HTTP: &http.Client{}}
|
||||
_, err := c.LookupArtist(context.Background(), "boards")
|
||||
if !errors.Is(err, ErrUnreachable) {
|
||||
t.Fatalf("err = %v, want ErrUnreachable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupArtist_BadJSON(t *testing.T) {
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`not json`))
|
||||
})
|
||||
defer srv.Close()
|
||||
_, err := c.LookupArtist(context.Background(), "boards")
|
||||
if !errors.Is(err, ErrInvalidPayload) {
|
||||
t.Fatalf("err = %v, want ErrInvalidPayload", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- LookupAlbum ---
|
||||
|
||||
func TestLookupAlbum_HappyPath(t *testing.T) {
|
||||
body := mustReadFixture(t, "lookup_album.json")
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/album/lookup" {
|
||||
t.Errorf("path = %q, want /api/v1/album/lookup", r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("term"); got != "geogaddi" {
|
||||
t.Errorf("term = %q, want geogaddi", got)
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
got, err := c.LookupAlbum(context.Background(), "geogaddi")
|
||||
if err != nil {
|
||||
t.Fatalf("LookupAlbum: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
// First result: year from releaseDate, trackCount in secondary.
|
||||
if got[0].MBID != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" {
|
||||
t.Errorf("MBID = %q", got[0].MBID)
|
||||
}
|
||||
if got[0].Name != "Music Has the Right to Children" {
|
||||
t.Errorf("Name = %q", got[0].Name)
|
||||
}
|
||||
want0 := "Boards of Canada · 1998 · 18 tracks"
|
||||
if got[0].Secondary != want0 {
|
||||
t.Errorf("Secondary = %q, want %q", got[0].Secondary, want0)
|
||||
}
|
||||
// Poster image from second image entry.
|
||||
if got[0].ImageURL != "https://example.invalid/mhtrtc-poster.jpg" {
|
||||
t.Errorf("ImageURL = %q", got[0].ImageURL)
|
||||
}
|
||||
|
||||
// Second result.
|
||||
want1 := "Boards of Canada · 2002 · 23 tracks"
|
||||
if got[1].Secondary != want1 {
|
||||
t.Errorf("Secondary = %q, want %q", got[1].Secondary, want1)
|
||||
}
|
||||
if got[1].ImageURL != "" {
|
||||
t.Errorf("expected empty ImageURL; got %q", got[1].ImageURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupAlbum_BadJSON(t *testing.T) {
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
})
|
||||
defer srv.Close()
|
||||
_, err := c.LookupAlbum(context.Background(), "x")
|
||||
if !errors.Is(err, ErrInvalidPayload) {
|
||||
t.Fatalf("err = %v, want ErrInvalidPayload (object, not array)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- LookupTrack ---
|
||||
|
||||
func TestLookupTrack_HappyPath(t *testing.T) {
|
||||
body := mustReadFixture(t, "lookup_track.json")
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/track/lookup" {
|
||||
t.Errorf("path = %q, want /api/v1/track/lookup", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
got, err := c.LookupTrack(context.Background(), "roygbiv")
|
||||
if err != nil {
|
||||
t.Fatalf("LookupTrack: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Name != "Roygbiv" {
|
||||
t.Errorf("Name = %q", got[0].Name)
|
||||
}
|
||||
want := "Music Has the Right to Children · Boards of Canada"
|
||||
if got[0].Secondary != want {
|
||||
t.Errorf("Secondary = %q, want %q", got[0].Secondary, want)
|
||||
}
|
||||
if got[0].ImageURL != "" {
|
||||
t.Errorf("tracks carry no images; ImageURL = %q", got[0].ImageURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupTrack_NoArtistName(t *testing.T) {
|
||||
// Verify secondary degrades gracefully when artistName is absent.
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`[{"foreignTrackId":"abc","title":"X","albumTitle":"AlbumY","artistName":""}]`))
|
||||
})
|
||||
defer srv.Close()
|
||||
got, err := c.LookupTrack(context.Background(), "x")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if got[0].Secondary != "AlbumY" {
|
||||
t.Errorf("Secondary = %q, want %q", got[0].Secondary, "AlbumY")
|
||||
}
|
||||
}
|
||||
|
||||
// --- AddArtist ---
|
||||
|
||||
func TestAddArtist_PostsCorrectBody(t *testing.T) {
|
||||
var decoded map[string]any
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/api/v1/artist" {
|
||||
t.Errorf("path = %q, want /api/v1/artist", r.URL.Path)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&decoded); err != nil {
|
||||
t.Errorf("decode body: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
err := c.AddArtist(context.Background(), AddArtistParams{
|
||||
ForeignArtistID: "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
|
||||
QualityProfileID: 2,
|
||||
RootFolderPath: "/music",
|
||||
MonitorAll: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddArtist: %v", err)
|
||||
}
|
||||
|
||||
if decoded["foreignArtistId"] != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" {
|
||||
t.Errorf("foreignArtistId = %v", decoded["foreignArtistId"])
|
||||
}
|
||||
if decoded["qualityProfileId"] != float64(2) {
|
||||
t.Errorf("qualityProfileId = %v", decoded["qualityProfileId"])
|
||||
}
|
||||
if decoded["rootFolderPath"] != "/music" {
|
||||
t.Errorf("rootFolderPath = %v", decoded["rootFolderPath"])
|
||||
}
|
||||
if decoded["monitored"] != true {
|
||||
t.Errorf("monitored = %v, want true", decoded["monitored"])
|
||||
}
|
||||
if decoded["monitor"] != "all" {
|
||||
t.Errorf("monitor = %v, want all (MonitorAll=true)", decoded["monitor"])
|
||||
}
|
||||
opts, ok := decoded["addOptions"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("addOptions missing or wrong type: %T", decoded["addOptions"])
|
||||
}
|
||||
if opts["searchForMissingAlbums"] != true {
|
||||
t.Errorf("addOptions.searchForMissingAlbums = %v, want true", opts["searchForMissingAlbums"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddArtist_MonitorFuture(t *testing.T) {
|
||||
var decoded map[string]any
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewDecoder(r.Body).Decode(&decoded)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
_ = c.AddArtist(context.Background(), AddArtistParams{MonitorAll: false})
|
||||
if decoded["monitor"] != "future" {
|
||||
t.Errorf("monitor = %v, want future (MonitorAll=false)", decoded["monitor"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddArtist_ServerError(t *testing.T) {
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
defer srv.Close()
|
||||
err := c.AddArtist(context.Background(), AddArtistParams{})
|
||||
if !errors.Is(err, ErrServerError) {
|
||||
t.Fatalf("err = %v, want ErrServerError", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- AddAlbum ---
|
||||
|
||||
func TestAddAlbum_PostsCorrectBody(t *testing.T) {
|
||||
var decoded map[string]any
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/api/v1/album" {
|
||||
t.Errorf("path = %q, want /api/v1/album", r.URL.Path)
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&decoded)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
err := c.AddAlbum(context.Background(), AddAlbumParams{
|
||||
ForeignAlbumID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
ForeignArtistID: "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
|
||||
QualityProfileID: 1,
|
||||
RootFolderPath: "/music",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddAlbum: %v", err)
|
||||
}
|
||||
|
||||
if decoded["foreignAlbumId"] != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" {
|
||||
t.Errorf("foreignAlbumId = %v", decoded["foreignAlbumId"])
|
||||
}
|
||||
if decoded["foreignArtistId"] != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" {
|
||||
t.Errorf("foreignArtistId = %v", decoded["foreignArtistId"])
|
||||
}
|
||||
if decoded["monitored"] != true {
|
||||
t.Errorf("monitored = %v, want true", decoded["monitored"])
|
||||
}
|
||||
opts, ok := decoded["addOptions"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("addOptions missing or wrong type: %T", decoded["addOptions"])
|
||||
}
|
||||
if opts["searchForNewAlbum"] != true {
|
||||
t.Errorf("addOptions.searchForNewAlbum = %v, want true", opts["searchForNewAlbum"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAlbum_AuthFailed(t *testing.T) {
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
})
|
||||
defer srv.Close()
|
||||
err := c.AddAlbum(context.Background(), AddAlbumParams{})
|
||||
if !errors.Is(err, ErrAuthFailed) {
|
||||
t.Fatalf("err = %v, want ErrAuthFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ListQualityProfiles ---
|
||||
|
||||
func TestListQualityProfiles_HappyPath(t *testing.T) {
|
||||
body := mustReadFixture(t, "quality_profiles.json")
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/qualityprofile" {
|
||||
t.Errorf("path = %q, want /api/v1/qualityprofile", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
got, err := c.ListQualityProfiles(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListQualityProfiles: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len = %d, want 3", len(got))
|
||||
}
|
||||
if got[0].ID != 1 || got[0].Name != "Any" {
|
||||
t.Errorf("first profile = %+v, want {ID:1 Name:Any}", got[0])
|
||||
}
|
||||
if got[1].ID != 2 || got[1].Name != "Lossless" {
|
||||
t.Errorf("second profile = %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListQualityProfiles_BadJSON(t *testing.T) {
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`not json`))
|
||||
})
|
||||
defer srv.Close()
|
||||
_, err := c.ListQualityProfiles(context.Background())
|
||||
if !errors.Is(err, ErrInvalidPayload) {
|
||||
t.Fatalf("err = %v, want ErrInvalidPayload", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ListRootFolders ---
|
||||
|
||||
func TestListRootFolders_HappyPath(t *testing.T) {
|
||||
body := mustReadFixture(t, "root_folders.json")
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/rootfolder" {
|
||||
t.Errorf("path = %q, want /api/v1/rootfolder", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
got, err := c.ListRootFolders(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListRootFolders: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len = %d, want 3", len(got))
|
||||
}
|
||||
if got[0].Path != "/music" {
|
||||
t.Errorf("Path = %q", got[0].Path)
|
||||
}
|
||||
if !got[0].Accessible {
|
||||
t.Errorf("Accessible = false for /music, want true")
|
||||
}
|
||||
if got[0].FreeSpace != 107374182400 {
|
||||
t.Errorf("FreeSpace = %d", got[0].FreeSpace)
|
||||
}
|
||||
if got[2].Accessible {
|
||||
t.Errorf("expected /music-offline to be inaccessible")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRootFolders_BadJSON(t *testing.T) {
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`null`))
|
||||
})
|
||||
defer srv.Close()
|
||||
// null decodes to a nil slice, no error — but len check should yield 0.
|
||||
got, err := c.ListRootFolders(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on null body: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty slice from null body, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Ping ---
|
||||
|
||||
func TestPing_ReturnsVersion(t *testing.T) {
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/system/status" {
|
||||
t.Errorf("path = %q, want /api/v1/system/status", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"version":"2.4.3.3795","buildTime":"2024-01-15T00:00:00Z"}`))
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
res, err := c.Ping(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Ping: %v", err)
|
||||
}
|
||||
if res.Version != "2.4.3.3795" {
|
||||
t.Errorf("Version = %q, want 2.4.3.3795", res.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPing_AuthFailed(t *testing.T) {
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
})
|
||||
defer srv.Close()
|
||||
_, err := c.Ping(context.Background())
|
||||
if !errors.Is(err, ErrAuthFailed) {
|
||||
t.Fatalf("err = %v, want ErrAuthFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPing_Unreachable(t *testing.T) {
|
||||
c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "x", HTTP: &http.Client{}}
|
||||
_, err := c.Ping(context.Background())
|
||||
if !errors.Is(err, ErrUnreachable) {
|
||||
t.Fatalf("err = %v, want ErrUnreachable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPing_BadJSON(t *testing.T) {
|
||||
c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{bad`))
|
||||
})
|
||||
defer srv.Close()
|
||||
_, err := c.Ping(context.Background())
|
||||
if !errors.Is(err, ErrInvalidPayload) {
|
||||
t.Fatalf("err = %v, want ErrInvalidPayload", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- pickPosterImage ---
|
||||
|
||||
func TestPickPosterImage_PreferRemoteURL(t *testing.T) {
|
||||
imgs := []struct {
|
||||
CoverType string `json:"coverType"`
|
||||
RemoteURL string `json:"remoteUrl"`
|
||||
URL string `json:"url"`
|
||||
}{
|
||||
{CoverType: "poster", RemoteURL: "https://remote.example/img.jpg", URL: "https://local.example/img.jpg"},
|
||||
}
|
||||
got := pickPosterImage(imgs)
|
||||
if got != "https://remote.example/img.jpg" {
|
||||
t.Errorf("got %q, want remote URL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickPosterImage_FallsBackToLocalURL(t *testing.T) {
|
||||
imgs := []struct {
|
||||
CoverType string `json:"coverType"`
|
||||
RemoteURL string `json:"remoteUrl"`
|
||||
URL string `json:"url"`
|
||||
}{
|
||||
{CoverType: "poster", RemoteURL: "", URL: "https://local.example/img.jpg"},
|
||||
}
|
||||
got := pickPosterImage(imgs)
|
||||
if got != "https://local.example/img.jpg" {
|
||||
t.Errorf("got %q, want local URL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickPosterImage_SkipsNonPoster(t *testing.T) {
|
||||
imgs := []struct {
|
||||
CoverType string `json:"coverType"`
|
||||
RemoteURL string `json:"remoteUrl"`
|
||||
URL string `json:"url"`
|
||||
}{
|
||||
{CoverType: "banner", RemoteURL: "https://banner.example/img.jpg"},
|
||||
{CoverType: "cover", RemoteURL: "https://cover.example/img.jpg"},
|
||||
}
|
||||
got := pickPosterImage(imgs)
|
||||
if got != "" {
|
||||
t.Errorf("got %q, want empty (no poster)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package lidarr
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors. Callers branch on these via errors.Is, not on
|
||||
// HTTP status codes — the client maps codes to errors.
|
||||
var (
|
||||
ErrUnreachable = errors.New("lidarr: unreachable")
|
||||
ErrAuthFailed = errors.New("lidarr: auth failed") // 401 / 403
|
||||
ErrLookupFailed = errors.New("lidarr: lookup failed") // 4xx other than 401/403
|
||||
ErrServerError = errors.New("lidarr: server error") // 5xx
|
||||
ErrInvalidPayload = errors.New("lidarr: invalid payload")
|
||||
)
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
[
|
||||
{
|
||||
"foreignAlbumId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"title": "Music Has the Right to Children",
|
||||
"artistName": "Boards of Canada",
|
||||
"releaseDate": "1998-04-27",
|
||||
"trackCount": 18,
|
||||
"images": [
|
||||
{"coverType": "cover", "remoteUrl": "https://example.invalid/mhtrtc.jpg", "url": ""},
|
||||
{"coverType": "poster", "remoteUrl": "https://example.invalid/mhtrtc-poster.jpg", "url": ""}
|
||||
]
|
||||
},
|
||||
{
|
||||
"foreignAlbumId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
|
||||
"title": "Geogaddi",
|
||||
"artistName": "Boards of Canada",
|
||||
"releaseDate": "2002-02-11",
|
||||
"trackCount": 23,
|
||||
"images": []
|
||||
}
|
||||
]
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
[
|
||||
{
|
||||
"foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
|
||||
"artistName": "Boards of Canada",
|
||||
"genres": ["Electronic", "IDM"],
|
||||
"albumCount": 18,
|
||||
"images": [
|
||||
{"coverType": "poster", "remoteUrl": "https://example.invalid/boc.jpg", "url": ""},
|
||||
{"coverType": "banner", "remoteUrl": "https://example.invalid/boc-banner.jpg", "url": ""}
|
||||
]
|
||||
},
|
||||
{
|
||||
"foreignArtistId": "f54ba20c-7da3-4b8a-9b12-22f09b9e2c1c",
|
||||
"artistName": "Bored of Education",
|
||||
"genres": [],
|
||||
"albumCount": 0,
|
||||
"images": []
|
||||
}
|
||||
]
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"foreignTrackId": "t1a2b3c4-d5e6-7890-abcd-ef1234567890",
|
||||
"title": "Roygbiv",
|
||||
"albumTitle": "Music Has the Right to Children",
|
||||
"artistName": "Boards of Canada"
|
||||
},
|
||||
{
|
||||
"foreignTrackId": "t2b3c4d5-e6f7-8901-bcde-f12345678901",
|
||||
"title": "Aquarius",
|
||||
"albumTitle": "Music Has the Right to Children",
|
||||
"artistName": "Boards of Canada"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{"id": 1, "name": "Any"},
|
||||
{"id": 2, "name": "Lossless"},
|
||||
{"id": 3, "name": "Standard"}
|
||||
]
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{"path": "/music", "accessible": true, "freeSpace": 107374182400},
|
||||
{"path": "/music-lossy", "accessible": true, "freeSpace": 53687091200},
|
||||
{"path": "/music-offline", "accessible": false, "freeSpace": 0}
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
// Package lidarr is a typed HTTP client for Lidarr's v1 API. It is the
|
||||
// only place in the codebase that knows about Lidarr's wire format.
|
||||
// Callers receive value structs, never raw JSON.
|
||||
package lidarr
|
||||
|
||||
// LookupResult is the normalized shape returned by Lookup{Artist,Album,Track}.
|
||||
// It is what we store on the request row (via the user's request) and
|
||||
// what /api/lidarr/search returns to the SPA.
|
||||
type LookupResult struct {
|
||||
MBID string // foreignArtistId / foreignAlbumId / foreignTrackId
|
||||
Name string // artist name; album/track returns Title here too
|
||||
Secondary string // genre + album count for artist; year for album; album for track
|
||||
ImageURL string // cover-art URL Lidarr surfaced (may be empty)
|
||||
}
|
||||
|
||||
// QualityProfile is the dropdown choice in /admin/integrations.
|
||||
type QualityProfile struct {
|
||||
ID int
|
||||
Name string
|
||||
}
|
||||
|
||||
// RootFolder is the dropdown choice in /admin/integrations.
|
||||
type RootFolder struct {
|
||||
Path string
|
||||
Accessible bool
|
||||
FreeSpace int64
|
||||
}
|
||||
|
||||
// AddArtistParams are the fields Lidarr requires on POST /api/v1/artist.
|
||||
type AddArtistParams struct {
|
||||
ForeignArtistID string
|
||||
QualityProfileID int
|
||||
RootFolderPath string
|
||||
MonitorAll bool // true => monitor="all"; false => "future"
|
||||
}
|
||||
|
||||
// AddAlbumParams are the fields Lidarr requires on POST /api/v1/album.
|
||||
type AddAlbumParams struct {
|
||||
ForeignAlbumID string
|
||||
ForeignArtistID string // Lidarr requires the artist's foreign id too
|
||||
QualityProfileID int
|
||||
RootFolderPath string
|
||||
}
|
||||
|
||||
// PingResult is the response shape from GET /api/v1/system/status.
|
||||
type PingResult struct {
|
||||
Version string
|
||||
}
|
||||
Reference in New Issue
Block a user