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:
2026-05-02 22:35:34 -04:00
parent 723eee9773
commit f6975cfad3
5 changed files with 583 additions and 206 deletions
+14 -1
View File
@@ -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
}
+176
View File
@@ -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)
}
}
+91
View File
@@ -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
}