diff --git a/internal/lidarr/client.go b/internal/lidarr/client.go new file mode 100644 index 00000000..48af1d3d --- /dev/null +++ b/internal/lidarr/client.go @@ -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 "" +} diff --git a/internal/lidarr/client_test.go b/internal/lidarr/client_test.go new file mode 100644 index 00000000..b8997e2e --- /dev/null +++ b/internal/lidarr/client_test.go @@ -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) + } +} diff --git a/internal/lidarr/errors.go b/internal/lidarr/errors.go new file mode 100644 index 00000000..0cdf98e2 --- /dev/null +++ b/internal/lidarr/errors.go @@ -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") +) diff --git a/internal/lidarr/testdata/lookup_album.json b/internal/lidarr/testdata/lookup_album.json new file mode 100644 index 00000000..c8901a50 --- /dev/null +++ b/internal/lidarr/testdata/lookup_album.json @@ -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": [] + } +] diff --git a/internal/lidarr/testdata/lookup_artist.json b/internal/lidarr/testdata/lookup_artist.json new file mode 100644 index 00000000..d3caab26 --- /dev/null +++ b/internal/lidarr/testdata/lookup_artist.json @@ -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": [] + } +] diff --git a/internal/lidarr/testdata/lookup_track.json b/internal/lidarr/testdata/lookup_track.json new file mode 100644 index 00000000..15230230 --- /dev/null +++ b/internal/lidarr/testdata/lookup_track.json @@ -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" + } +] diff --git a/internal/lidarr/testdata/quality_profiles.json b/internal/lidarr/testdata/quality_profiles.json new file mode 100644 index 00000000..b645820e --- /dev/null +++ b/internal/lidarr/testdata/quality_profiles.json @@ -0,0 +1,5 @@ +[ + {"id": 1, "name": "Any"}, + {"id": 2, "name": "Lossless"}, + {"id": 3, "name": "Standard"} +] diff --git a/internal/lidarr/testdata/root_folders.json b/internal/lidarr/testdata/root_folders.json new file mode 100644 index 00000000..b75eef9e --- /dev/null +++ b/internal/lidarr/testdata/root_folders.json @@ -0,0 +1,5 @@ +[ + {"path": "/music", "accessible": true, "freeSpace": 107374182400}, + {"path": "/music-lossy", "accessible": true, "freeSpace": 53687091200}, + {"path": "/music-offline", "accessible": false, "freeSpace": 0} +] diff --git a/internal/lidarr/types.go b/internal/lidarr/types.go new file mode 100644 index 00000000..0c4441ba --- /dev/null +++ b/internal/lidarr/types.go @@ -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 +}