diff --git a/internal/lidarr/delete.go b/internal/lidarr/delete.go new file mode 100644 index 00000000..2a060320 --- /dev/null +++ b/internal/lidarr/delete.go @@ -0,0 +1,69 @@ +package lidarr + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" +) + +// del is the shared DELETE helper. Mirrors get()/post() in client.go: builds +// the URL inline, sets the API key, maps status codes to typed errors. The +// caller is responsible for closing the returned body. +func (c *Client) del(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.MethodDelete, 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 +} + +// DeleteAlbum removes an album from Lidarr's library. +// - deleteFiles=true also removes the audio files from disk. +// - addImportListExclusion=true tells Lidarr to never re-add this album +// via import-list scans. +// +// M5b's admin "delete via Lidarr" action always passes both `true`. +func (c *Client) DeleteAlbum(ctx context.Context, lidarrAlbumID int, deleteFiles, addImportListExclusion bool) error { + if lidarrAlbumID == 0 { + return fmt.Errorf("lidarr: zero album id") + } + q := url.Values{ + "deleteFiles": []string{strconv.FormatBool(deleteFiles)}, + "addImportListExclusion": []string{strconv.FormatBool(addImportListExclusion)}, + } + resp, err := c.del(ctx, "/api/v1/album/"+strconv.Itoa(lidarrAlbumID), q) + if err != nil { + return err + } + resp.Body.Close() + return nil +} diff --git a/internal/lidarr/delete_test.go b/internal/lidarr/delete_test.go new file mode 100644 index 00000000..78d6789c --- /dev/null +++ b/internal/lidarr/delete_test.go @@ -0,0 +1,178 @@ +package lidarr + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestLookupAlbumByMBID_HappyPath(t *testing.T) { + body, err := os.ReadFile("testdata/album_lookup_by_mbid.json") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/album" { + t.Errorf("path = %q, want /api/v1/album", r.URL.Path) + } + if got := r.URL.Query().Get("foreignAlbumId"); got != "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d" { + t.Errorf("foreignAlbumId = %q", got) + } + if got := r.Header.Get("X-Api-Key"); got != "key123" { + t.Errorf("X-Api-Key = %q, want key123", got) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + }) + defer srv.Close() + + got, err := c.LookupAlbumByMBID(context.Background(), "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d") + if err != nil { + t.Fatalf("LookupAlbumByMBID: %v", err) + } + if got.ID != 42 || got.Title != "Music Has The Right To Children" { + t.Errorf("got = %+v", got) + } +} + +func TestLookupAlbumByMBID_EmptyArrayReturnsErrNotFound(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("[]")) + }) + defer srv.Close() + + _, err := c.LookupAlbumByMBID(context.Background(), "x") + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestLookupAlbumByMBID_AuthFailed(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + defer srv.Close() + + _, err := c.LookupAlbumByMBID(context.Background(), "x") + if !errors.Is(err, ErrAuthFailed) { + t.Errorf("err = %v, want ErrAuthFailed", err) + } +} + +func TestLookupAlbumByMBID_5xxReturnsErrServerError(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + defer srv.Close() + + _, err := c.LookupAlbumByMBID(context.Background(), "x") + if !errors.Is(err, ErrServerError) { + t.Errorf("err = %v, want ErrServerError", err) + } +} + +func TestLookupAlbumByMBID_BadJSON(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not json")) + }) + defer srv.Close() + + _, err := c.LookupAlbumByMBID(context.Background(), "x") + if err == nil { + t.Fatal("err = nil, want decode error") + } +} + +func TestLookupArtistByMBID_HappyPath(t *testing.T) { + body, err := os.ReadFile("testdata/artist_lookup_by_mbid.json") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/artist" { + t.Errorf("path = %q, want /api/v1/artist", r.URL.Path) + } + if got := r.URL.Query().Get("mbId"); got != "069b64b6-7884-4f6a-94cc-e4c1d6c87a01" { + t.Errorf("mbId = %q", got) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + }) + defer srv.Close() + + got, err := c.LookupArtistByMBID(context.Background(), "069b64b6-7884-4f6a-94cc-e4c1d6c87a01") + if err != nil { + t.Fatalf("LookupArtistByMBID: %v", err) + } + if got.ID != 7 || got.ArtistName != "Boards of Canada" { + t.Errorf("got = %+v", got) + } +} + +func TestDeleteAlbum_PassesBothFlags(t *testing.T) { + var captured *http.Request + c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) { + captured = r + w.WriteHeader(http.StatusOK) + }) + defer srv.Close() + + if err := c.DeleteAlbum(context.Background(), 42, true, true); err != nil { + t.Fatalf("DeleteAlbum: %v", err) + } + if captured == nil || captured.Method != http.MethodDelete { + t.Fatalf("method = %v, want DELETE", captured) + } + if captured.URL.Path != "/api/v1/album/42" { + t.Errorf("path = %q", captured.URL.Path) + } + if got := captured.URL.Query().Get("deleteFiles"); got != "true" { + t.Errorf("deleteFiles = %q", got) + } + if got := captured.URL.Query().Get("addImportListExclusion"); got != "true" { + t.Errorf("addImportListExclusion = %q", got) + } +} + +func TestDeleteAlbum_5xxReturnsErrServerError(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + defer srv.Close() + + err := c.DeleteAlbum(context.Background(), 42, true, true) + if !errors.Is(err, ErrServerError) { + t.Errorf("err = %v, want ErrServerError", err) + } +} + +func TestDeleteAlbum_NetworkErrorReturnsErrUnreachable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + srv.Close() // server is closed; client should fail to connect + c := NewClient(srv.URL, "test-key") + + err := c.DeleteAlbum(context.Background(), 42, true, true) + if !errors.Is(err, ErrUnreachable) { + t.Errorf("err = %v, want ErrUnreachable", err) + } +} + +func TestDeleteAlbum_ZeroIDRejected(t *testing.T) { + c, srv := newTestClient(func(w http.ResponseWriter, _ *http.Request) { + t.Error("server should not be called with zero id") + }) + defer srv.Close() + + if err := c.DeleteAlbum(context.Background(), 0, true, true); err == nil { + t.Error("err = nil, want zero-id rejection") + } +} diff --git a/internal/lidarr/errors.go b/internal/lidarr/errors.go index 0cdf98e2..e852428f 100644 --- a/internal/lidarr/errors.go +++ b/internal/lidarr/errors.go @@ -10,4 +10,10 @@ var ( ErrLookupFailed = errors.New("lidarr: lookup failed") // 4xx other than 401/403 ErrServerError = errors.New("lidarr: server error") // 5xx ErrInvalidPayload = errors.New("lidarr: invalid payload") + // ErrNotFound is returned by LookupArtistByMBID and LookupAlbumByMBID + // when Lidarr returns 200 with an empty array — i.e., the MBID isn't in + // Lidarr's monitored set. Distinguished from network/auth errors so admin + // handlers can surface it as `lidarr_album_lookup_failed` (502) instead + // of `lidarr_unreachable` (503). + ErrNotFound = errors.New("lidarr: not found") ) diff --git a/internal/lidarr/lookup_mbid.go b/internal/lidarr/lookup_mbid.go new file mode 100644 index 00000000..89e839c8 --- /dev/null +++ b/internal/lidarr/lookup_mbid.go @@ -0,0 +1,54 @@ +package lidarr + +import ( + "context" + "encoding/json" + "fmt" + "net/url" +) + +// LookupArtistByMBID returns the artist Lidarr has indexed under that +// MBID. Returns ErrNotFound if Lidarr returns an empty array. +func (c *Client) LookupArtistByMBID(ctx context.Context, mbid string) (LidarrArtist, error) { + if mbid == "" { + return LidarrArtist{}, fmt.Errorf("lidarr: empty mbid") + } + q := url.Values{"mbId": []string{mbid}} + resp, err := c.get(ctx, "/api/v1/artist", q) + if err != nil { + return LidarrArtist{}, err + } + defer resp.Body.Close() + + var rows []LidarrArtist + if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { + return LidarrArtist{}, fmt.Errorf("lidarr: decode artist: %w", err) + } + if len(rows) == 0 { + return LidarrArtist{}, ErrNotFound + } + return rows[0], nil +} + +// LookupAlbumByMBID returns the album Lidarr has indexed under that +// MBID. Returns ErrNotFound on empty result. +func (c *Client) LookupAlbumByMBID(ctx context.Context, mbid string) (LidarrAlbum, error) { + if mbid == "" { + return LidarrAlbum{}, fmt.Errorf("lidarr: empty mbid") + } + q := url.Values{"foreignAlbumId": []string{mbid}} + resp, err := c.get(ctx, "/api/v1/album", q) + if err != nil { + return LidarrAlbum{}, err + } + defer resp.Body.Close() + + var rows []LidarrAlbum + if err := json.NewDecoder(resp.Body).Decode(&rows); err != nil { + return LidarrAlbum{}, fmt.Errorf("lidarr: decode album: %w", err) + } + if len(rows) == 0 { + return LidarrAlbum{}, ErrNotFound + } + return rows[0], nil +} diff --git a/internal/lidarr/testdata/album_lookup_by_mbid.json b/internal/lidarr/testdata/album_lookup_by_mbid.json new file mode 100644 index 00000000..90d38049 --- /dev/null +++ b/internal/lidarr/testdata/album_lookup_by_mbid.json @@ -0,0 +1,8 @@ +[ + { + "id": 42, + "foreignAlbumId": "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d", + "title": "Music Has The Right To Children", + "artistId": 7 + } +] diff --git a/internal/lidarr/testdata/artist_lookup_by_mbid.json b/internal/lidarr/testdata/artist_lookup_by_mbid.json new file mode 100644 index 00000000..74d78984 --- /dev/null +++ b/internal/lidarr/testdata/artist_lookup_by_mbid.json @@ -0,0 +1,7 @@ +[ + { + "id": 7, + "foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", + "artistName": "Boards of Canada" + } +] diff --git a/internal/lidarr/types.go b/internal/lidarr/types.go index d1c8ec05..8e4c8052 100644 --- a/internal/lidarr/types.go +++ b/internal/lidarr/types.go @@ -58,3 +58,21 @@ type AddAlbumParams struct { type PingResult struct { Version string } + +// LidarrArtist is the subset of Lidarr's artist resource used by M5b +// admin actions. The "id" field is Lidarr's internal numeric ID — needed +// for DELETE /api/v1/artist/{id} calls. +type LidarrArtist struct { + ID int `json:"id"` + ForeignArtistID string `json:"foreignArtistId"` // MBID + ArtistName string `json:"artistName"` +} + +// LidarrAlbum is the subset of Lidarr's album resource used by M5b +// admin actions. +type LidarrAlbum struct { + ID int `json:"id"` + ForeignAlbumID string `json:"foreignAlbumId"` // MBID + Title string `json:"title"` + ArtistID int `json:"artistId"` +}