fix(tracks): rewrite RemoveTrack — direct os.Remove + optional Lidarr unmonitor
Replaces commit 50a231f's wrong-shape Lidarr-routed delete. The previous
design called lidarrquarantine.DeleteViaLidarr for Lidarr-managed tracks,
which deletes the entire **album** in Lidarr (Lidarr is album-granular,
no per-track delete API) — silently dropping sibling tracks the operator
didn't ask to remove.
New shape per spec revision 723eee9:
- Always: os.Remove + DB delete + cascade through Minstrel.
- When unmonitor=true AND track has mbid: call new Lidarr.UnmonitorTrack
primitive so Lidarr won't replace. Failure is non-fatal — file is
already gone, DB consistent; the lidarrUnmonitorFailed bool flows to
the wire response so the UI can surface a follow-up "unmonitor
manually" toast.
Service signature changes from `(trackID, adminID) → (delAlbum, delArtist, err)`
to `(trackID, adminID, unmonitor) → (delAlbum, delArtist, lidarrFailed, err)`.
Adds Lidarr.UnmonitorTrack with full three-step API walk:
1. GET /api/v1/album?foreignAlbumId={mbid} → Lidarr's internal album id
2. GET /api/v1/track?albumId={id} → match track by foreignTrackId
3. PUT /api/v1/track/monitor with {trackIds:[id], monitored:false}
Refactors post() into a shared bodyRequest() so put() reuses the same
status-code → typed-error mapping. The album mbid is captured *before*
the cascade-delete transaction — DeleteAlbumIfEmpty may remove the
album row, after which a post-commit GetAlbumByID returns ErrNoRows
and the unmonitor walk would have nothing to walk against.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -83,12 +83,25 @@ func readBodySnippet(r io.Reader) string {
|
||||
// status codes to typed errors; the caller is responsible for closing the
|
||||
// returned body.
|
||||
func (c *Client) post(ctx context.Context, path string, body []byte) (*http.Response, error) {
|
||||
return c.bodyRequest(ctx, http.MethodPost, path, body)
|
||||
}
|
||||
|
||||
// put is the shared PUT helper. Body must be marshaled JSON. Mirrors post() —
|
||||
// same status-code mapping, same caller-closes-body contract.
|
||||
func (c *Client) put(ctx context.Context, path string, body []byte) (*http.Response, error) {
|
||||
return c.bodyRequest(ctx, http.MethodPut, path, body)
|
||||
}
|
||||
|
||||
// bodyRequest is the shared core for POST and PUT. Lidarr's monitor toggle
|
||||
// is PUT-shaped, so factoring it out keeps the post/put helpers from
|
||||
// duplicating the status-code mapping.
|
||||
func (c *Client) bodyRequest(ctx context.Context, method, path string, body []byte) (*http.Response, error) {
|
||||
u, err := url.Parse(c.BaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/") + path
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body))
|
||||
req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -605,3 +605,179 @@ func TestPickPosterImage_SkipsNonPoster(t *testing.T) {
|
||||
t.Errorf("got %q, want empty (no poster)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- UnmonitorTrack ---
|
||||
|
||||
// unmonitorMux is the three-endpoint stub UnmonitorTrack walks. The flags it
|
||||
// captures are inspected by the individual tests.
|
||||
type unmonitorMux struct {
|
||||
t *testing.T
|
||||
wantAlbumMbid string
|
||||
albumStatus int // override status for album lookup; 0 → 200 with body
|
||||
tracksStatus int // override status for tracks list; 0 → 200 with body
|
||||
putStatus int // override status for PUT /track/monitor; 0 → 200
|
||||
albumBody []byte // when albumStatus is 0
|
||||
tracksBody []byte // when tracksStatus is 0
|
||||
putBody map[string]any
|
||||
putHit bool
|
||||
}
|
||||
|
||||
func (m *unmonitorMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/album":
|
||||
if got := r.URL.Query().Get("foreignAlbumId"); got != m.wantAlbumMbid {
|
||||
m.t.Errorf("album lookup foreignAlbumId = %q, want %q", got, m.wantAlbumMbid)
|
||||
}
|
||||
if m.albumStatus != 0 {
|
||||
w.WriteHeader(m.albumStatus)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(m.albumBody)
|
||||
case "/api/v1/track":
|
||||
if got := r.URL.Query().Get("albumId"); got != "42" {
|
||||
m.t.Errorf("track list albumId = %q, want 42", got)
|
||||
}
|
||||
if m.tracksStatus != 0 {
|
||||
w.WriteHeader(m.tracksStatus)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(m.tracksBody)
|
||||
case "/api/v1/track/monitor":
|
||||
if r.Method != http.MethodPut {
|
||||
m.t.Errorf("monitor method = %q, want PUT", r.Method)
|
||||
}
|
||||
m.putHit = true
|
||||
if m.putStatus != 0 {
|
||||
w.WriteHeader(m.putStatus)
|
||||
return
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&m.putBody)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
default:
|
||||
m.t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmonitorTrack_HappyPath(t *testing.T) {
|
||||
mux := &unmonitorMux{
|
||||
t: t,
|
||||
wantAlbumMbid: "album-mbid",
|
||||
albumBody: []byte(`[{"id":42,"foreignAlbumId":"album-mbid","title":"X","artistId":7}]`),
|
||||
tracksBody: []byte(`[{"id":1001,"foreignTrackId":"other-mbid"},{"id":1002,"foreignTrackId":"track-mbid"}]`),
|
||||
}
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()}
|
||||
|
||||
if err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid"); err != nil {
|
||||
t.Fatalf("UnmonitorTrack: %v", err)
|
||||
}
|
||||
if !mux.putHit {
|
||||
t.Fatal("PUT /api/v1/track/monitor was not called")
|
||||
}
|
||||
if mux.putBody["monitored"] != false {
|
||||
t.Errorf("PUT body monitored = %v, want false", mux.putBody["monitored"])
|
||||
}
|
||||
ids, ok := mux.putBody["trackIds"].([]any)
|
||||
if !ok || len(ids) != 1 {
|
||||
t.Fatalf("PUT body trackIds = %v, want [1002]", mux.putBody["trackIds"])
|
||||
}
|
||||
if ids[0] != float64(1002) {
|
||||
t.Errorf("PUT trackIds[0] = %v, want 1002 (the matching foreignTrackId)", ids[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmonitorTrack_EmptyMbid(t *testing.T) {
|
||||
c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "k", HTTP: &http.Client{}}
|
||||
if err := c.UnmonitorTrack(context.Background(), "", "album-mbid"); err == nil {
|
||||
t.Error("UnmonitorTrack with empty trackMbid: want error, got nil")
|
||||
}
|
||||
if err := c.UnmonitorTrack(context.Background(), "track-mbid", ""); err == nil {
|
||||
t.Error("UnmonitorTrack with empty albumMbid: want error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmonitorTrack_AlbumNotInLidarr(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/album" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()}
|
||||
err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmonitorTrack_TrackNotInAlbum(t *testing.T) {
|
||||
mux := &unmonitorMux{
|
||||
t: t,
|
||||
wantAlbumMbid: "album-mbid",
|
||||
albumBody: []byte(`[{"id":42,"foreignAlbumId":"album-mbid","title":"X","artistId":7}]`),
|
||||
// No track with foreignTrackId="track-mbid"
|
||||
tracksBody: []byte(`[{"id":1001,"foreignTrackId":"other-mbid"}]`),
|
||||
}
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()}
|
||||
err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
if mux.putHit {
|
||||
t.Error("PUT was called even though track wasn't found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmonitorTrack_ServerError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()}
|
||||
err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid")
|
||||
if !errors.Is(err, ErrServerError) {
|
||||
t.Fatalf("err = %v, want ErrServerError", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmonitorTrack_AuthFailed(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()}
|
||||
err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid")
|
||||
if !errors.Is(err, ErrAuthFailed) {
|
||||
t.Fatalf("err = %v, want ErrAuthFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmonitorTrack_Unreachable(t *testing.T) {
|
||||
c := &Client{BaseURL: "http://127.0.0.1:1", APIKey: "k", HTTP: &http.Client{}}
|
||||
err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid")
|
||||
if !errors.Is(err, ErrUnreachable) {
|
||||
t.Fatalf("err = %v, want ErrUnreachable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmonitorTrack_PutServerError(t *testing.T) {
|
||||
mux := &unmonitorMux{
|
||||
t: t,
|
||||
wantAlbumMbid: "album-mbid",
|
||||
albumBody: []byte(`[{"id":42,"foreignAlbumId":"album-mbid","title":"X","artistId":7}]`),
|
||||
tracksBody: []byte(`[{"id":1002,"foreignTrackId":"track-mbid"}]`),
|
||||
putStatus: http.StatusInternalServerError,
|
||||
}
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
c := &Client{BaseURL: srv.URL, APIKey: "k", HTTP: srv.Client()}
|
||||
err := c.UnmonitorTrack(context.Background(), "track-mbid", "album-mbid")
|
||||
if !errors.Is(err, ErrServerError) {
|
||||
t.Fatalf("err = %v, want ErrServerError on PUT 5xx", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package lidarr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// UnmonitorTrack flips Lidarr's `monitored` flag to false on the track
|
||||
// matching trackMbid (a MusicBrainz id, aka Lidarr's `foreignTrackId`).
|
||||
// Used by the M7 #372 admin "Remove from library" flow when the operator
|
||||
// requests `?unmonitor=true` so Lidarr's monitor doesn't search for a
|
||||
// replacement after we delete the local file + DB rows.
|
||||
//
|
||||
// Lidarr's monitor API takes its own internal numeric id, not the mbid,
|
||||
// so this is a three-step walk:
|
||||
//
|
||||
// 1. GET /api/v1/album?foreignAlbumId={albumMbid} — find the album's
|
||||
// internal Lidarr id. albumMbid is the parent album's mbid; the
|
||||
// caller (tracks service) passes it because looking it up from the
|
||||
// trackMbid alone would mean either a dedicated Lidarr endpoint
|
||||
// that doesn't exist or scanning the entire library.
|
||||
// 2. GET /api/v1/track?albumId={lidarr_album_id} — list the album's
|
||||
// tracks, each with its internal id and foreignTrackId.
|
||||
// 3. PUT /api/v1/track/monitor — body `{trackIds:[id], monitored:false}`.
|
||||
//
|
||||
// Returns ErrNotFound when the album or track isn't in Lidarr's library
|
||||
// (already removed, or the track was never imported). Network / auth /
|
||||
// 5xx errors propagate as typed sentinel errors so the tracks service
|
||||
// can decide whether to surface a `lidarr_unmonitor_failed` flag.
|
||||
func (c *Client) UnmonitorTrack(ctx context.Context, trackMbid, albumMbid string) error {
|
||||
if trackMbid == "" {
|
||||
return fmt.Errorf("lidarr: empty track mbid")
|
||||
}
|
||||
if albumMbid == "" {
|
||||
return fmt.Errorf("lidarr: empty album mbid")
|
||||
}
|
||||
|
||||
// Step 1: resolve album mbid → Lidarr album id.
|
||||
album, err := c.LookupAlbumByMBID(ctx, albumMbid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if album.ID == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
// Step 2: list tracks under that album, find the one whose
|
||||
// foreignTrackId matches.
|
||||
q := url.Values{"albumId": []string{strconv.Itoa(album.ID)}}
|
||||
resp, err := c.get(ctx, "/api/v1/track", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var tracks []struct {
|
||||
ID int `json:"id"`
|
||||
ForeignTrackID string `json:"foreignTrackId"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tracks); err != nil {
|
||||
return fmt.Errorf("%w: decode tracks: %v", ErrInvalidPayload, err)
|
||||
}
|
||||
var lidarrTrackID int
|
||||
for _, t := range tracks {
|
||||
if t.ForeignTrackID == trackMbid {
|
||||
lidarrTrackID = t.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if lidarrTrackID == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
// Step 3: PUT the monitor toggle.
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"trackIds": []int{lidarrTrackID},
|
||||
"monitored": false,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err)
|
||||
}
|
||||
putResp, err := c.put(ctx, "/api/v1/track/monitor", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = putResp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user