feat(lidarr): LookupArtistByMBID, LookupAlbumByMBID, DeleteAlbum
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,4 +10,10 @@ var (
|
|||||||
ErrLookupFailed = errors.New("lidarr: lookup failed") // 4xx other than 401/403
|
ErrLookupFailed = errors.New("lidarr: lookup failed") // 4xx other than 401/403
|
||||||
ErrServerError = errors.New("lidarr: server error") // 5xx
|
ErrServerError = errors.New("lidarr: server error") // 5xx
|
||||||
ErrInvalidPayload = errors.New("lidarr: invalid payload")
|
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")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"foreignAlbumId": "3a2c2c8c-7e6f-4f8a-b1d2-9a8b6c4e3f1d",
|
||||||
|
"title": "Music Has The Right To Children",
|
||||||
|
"artistId": 7
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"foreignArtistId": "069b64b6-7884-4f6a-94cc-e4c1d6c87a01",
|
||||||
|
"artistName": "Boards of Canada"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -58,3 +58,21 @@ type AddAlbumParams struct {
|
|||||||
type PingResult struct {
|
type PingResult struct {
|
||||||
Version string
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user