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:
+206
-101
@@ -15,7 +15,6 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||
)
|
||||
|
||||
// --- test seed helpers (inlined; mirrors lidarrquarantine patterns) -----
|
||||
@@ -53,12 +52,12 @@ func seedAdmin(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
|
||||
return u
|
||||
}
|
||||
|
||||
// seedArtistAlbumTrack creates a (test-prefixed) artist + album + track
|
||||
// row triple. mbid is set on both the album and the track when non-empty;
|
||||
// empty mbid leaves both nil so the track exercises the local-delete path.
|
||||
// filePath is the on-disk path stored on the track row. Returns the three
|
||||
// rows. Uses unique names per call so multiple seeds in one test don't
|
||||
// collide on UNIQUE (artist, name) etc.
|
||||
// seedArtistAlbumTrack creates an artist + album + track triple. mbid is
|
||||
// set on both the album and the track when non-empty (the album mbid is
|
||||
// derived from the track mbid suffix); empty leaves both nil so the
|
||||
// track exercises the local-only path. filePath is the on-disk path
|
||||
// stored on the track row. Names are unique per call so multiple seeds
|
||||
// in one test don't collide on UNIQUE constraints.
|
||||
func seedArtistAlbumTrack(t *testing.T, pool *pgxpool.Pool, prefix, mbid, filePath string) (dbq.Artist, dbq.Album, dbq.Track) {
|
||||
t.Helper()
|
||||
q := dbq.New(pool)
|
||||
@@ -96,19 +95,21 @@ func seedArtistAlbumTrack(t *testing.T, pool *pgxpool.Pool, prefix, mbid, filePa
|
||||
return artist, album, track
|
||||
}
|
||||
|
||||
// --- fake LidarrDeleter -------------------------------------------------
|
||||
// --- fake LidarrUnmonitorer --------------------------------------------
|
||||
|
||||
type fakeLidarrDeleter struct {
|
||||
calls []pgtype.UUID
|
||||
err error
|
||||
// fakeLidarrUnmonitorer captures call args + a configurable error so
|
||||
// tests can assert "called with mbid X" and "RemoveTrack tolerated this
|
||||
// Lidarr error."
|
||||
type fakeLidarrUnmonitorer struct {
|
||||
calls []string // captured trackMbid per call
|
||||
albumMbid string // last albumMbid received
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeLidarrDeleter) DeleteViaLidarr(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, int, error) {
|
||||
f.calls = append(f.calls, trackID)
|
||||
if f.err != nil {
|
||||
return dbq.LidarrQuarantineAction{}, 0, f.err
|
||||
}
|
||||
return dbq.LidarrQuarantineAction{}, 1, nil
|
||||
func (f *fakeLidarrUnmonitorer) UnmonitorTrack(ctx context.Context, trackMbid, albumMbid string) error {
|
||||
f.calls = append(f.calls, trackMbid)
|
||||
f.albumMbid = albumMbid
|
||||
return f.err
|
||||
}
|
||||
|
||||
// --- tests --------------------------------------------------------------
|
||||
@@ -122,13 +123,13 @@ func TestRemoveTrack_NotFound(t *testing.T) {
|
||||
bogus.Valid = true
|
||||
|
||||
svc := NewService(pool, nil, nil)
|
||||
_, _, err := svc.RemoveTrack(context.Background(), bogus, admin.ID)
|
||||
_, _, _, err := svc.RemoveTrack(context.Background(), bogus, admin.ID, false)
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveTrack_NonLidarr_HappyPath(t *testing.T) {
|
||||
func TestRemoveTrack_LocalFile_HappyPath(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
admin := seedAdmin(t, pool, "alice")
|
||||
|
||||
@@ -137,14 +138,18 @@ func TestRemoveTrack_NonLidarr_HappyPath(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// mbid empty → local-delete path.
|
||||
// mbid empty → no Lidarr branch even with unmonitor=true.
|
||||
_, _, track := seedArtistAlbumTrack(t, pool, "Solo", "", path)
|
||||
|
||||
svc := NewService(pool, nil, nil)
|
||||
_, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID)
|
||||
fake := &fakeLidarrUnmonitorer{}
|
||||
svc := NewService(pool, nil, fake)
|
||||
_, _, lidarrFailed, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveTrack: %v", err)
|
||||
}
|
||||
if lidarrFailed {
|
||||
t.Error("lidarrUnmonitorFailed = true; want false on local-file happy path")
|
||||
}
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("file still present: %v", err)
|
||||
}
|
||||
@@ -152,18 +157,21 @@ func TestRemoveTrack_NonLidarr_HappyPath(t *testing.T) {
|
||||
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
|
||||
t.Errorf("track row still exists")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Errorf("Lidarr called %d times; want 0 (no mbid, unmonitor=false)", len(fake.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveTrack_NonLidarr_FileAlreadyMissing(t *testing.T) {
|
||||
func TestRemoveTrack_FileAlreadyMissing(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
admin := seedAdmin(t, pool, "alice")
|
||||
|
||||
// Path doesn't exist on disk; service should tolerate and clean up DB.
|
||||
// Path doesn't exist on disk; service must tolerate and clean up DB.
|
||||
missing := filepath.Join(t.TempDir(), "does-not-exist.mp3")
|
||||
_, _, track := seedArtistAlbumTrack(t, pool, "Ghost", "", missing)
|
||||
|
||||
svc := NewService(pool, nil, nil)
|
||||
_, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID)
|
||||
_, _, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveTrack: %v", err)
|
||||
}
|
||||
@@ -173,79 +181,6 @@ func TestRemoveTrack_NonLidarr_FileAlreadyMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveTrack_Lidarr_HappyPath(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
admin := seedAdmin(t, pool, "alice")
|
||||
|
||||
// mbid set → Lidarr path. Note: real lidarrquarantine.DeleteViaLidarr
|
||||
// would have removed the tracks row already; the fake doesn't, so we
|
||||
// use a track whose row will linger but cascadeAfterLidarr only acts
|
||||
// on album/artist parents. The DB row staying behind for this test
|
||||
// case is acceptable; the unit-of-work here is "DeleteViaLidarr was
|
||||
// called, with the right trackID".
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "lidarr.mp3")
|
||||
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, track := seedArtistAlbumTrack(t, pool, "Lidarr", "track-mbid", path)
|
||||
|
||||
fake := &fakeLidarrDeleter{}
|
||||
svc := NewService(pool, nil, fake)
|
||||
_, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveTrack: %v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("DeleteViaLidarr call count = %d, want 1", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0] != track.ID {
|
||||
t.Errorf("DeleteViaLidarr called with %v, want %v", fake.calls[0], track.ID)
|
||||
}
|
||||
// File should still exist — the fake doesn't actually delete it,
|
||||
// and the local-os.Remove fallback is not exercised on the Lidarr path.
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Errorf("file unexpectedly missing on lidarr path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveTrack_Lidarr_ErrorPropagation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
lidarrE error
|
||||
wantE error
|
||||
}{
|
||||
{"unreachable", lidarr.ErrUnreachable, ErrLidarrUnreachable},
|
||||
{"unauthorized", lidarr.ErrAuthFailed, ErrLidarrUnauthorized},
|
||||
{"server", lidarr.ErrServerError, ErrLidarrServerError},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
admin := seedAdmin(t, pool, "alice")
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "lidarr.mp3")
|
||||
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, track := seedArtistAlbumTrack(t, pool, "LidErr", "track-mbid-"+tc.name, path)
|
||||
|
||||
fake := &fakeLidarrDeleter{err: tc.lidarrE}
|
||||
svc := NewService(pool, nil, fake)
|
||||
_, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID)
|
||||
if !errors.Is(err, tc.wantE) {
|
||||
t.Fatalf("err = %v, want %v", err, tc.wantE)
|
||||
}
|
||||
// DB row must remain — Lidarr error means no local mutation.
|
||||
q := dbq.New(pool)
|
||||
if _, gerr := q.GetTrackByID(context.Background(), track.ID); gerr != nil {
|
||||
t.Errorf("track row missing after Lidarr error: %v", gerr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveTrack_Cascade_AlbumEmpty(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
admin := seedAdmin(t, pool, "alice")
|
||||
@@ -292,7 +227,7 @@ func TestRemoveTrack_Cascade_AlbumEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
svc := NewService(pool, nil, nil)
|
||||
delAlbum, delArtist, err := svc.RemoveTrack(context.Background(), trackA.ID, admin.ID)
|
||||
delAlbum, delArtist, _, err := svc.RemoveTrack(context.Background(), trackA.ID, admin.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveTrack: %v", err)
|
||||
}
|
||||
@@ -302,7 +237,6 @@ func TestRemoveTrack_Cascade_AlbumEmpty(t *testing.T) {
|
||||
if delArtist != nil {
|
||||
t.Errorf("deletedArtistID = %v, want nil (artist still has album B)", delArtist)
|
||||
}
|
||||
// Album A should be gone; album B should still exist.
|
||||
if _, err := q.GetAlbumByID(context.Background(), albumA.ID); err == nil {
|
||||
t.Errorf("album A still exists after cascade")
|
||||
}
|
||||
@@ -326,7 +260,7 @@ func TestRemoveTrack_Cascade_ArtistEmpty(t *testing.T) {
|
||||
artist, album, track := seedArtistAlbumTrack(t, pool, "Lone", "", path)
|
||||
|
||||
svc := NewService(pool, nil, nil)
|
||||
delAlbum, delArtist, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID)
|
||||
delAlbum, delArtist, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveTrack: %v", err)
|
||||
}
|
||||
@@ -344,3 +278,174 @@ func TestRemoveTrack_Cascade_ArtistEmpty(t *testing.T) {
|
||||
t.Errorf("artist still exists after cascade")
|
||||
}
|
||||
}
|
||||
|
||||
// unmonitor=true AND mbid set → Lidarr called once; lidarrUnmonitorFailed=false.
|
||||
// Uses two albums on the same artist so the album's row survives the
|
||||
// cascade — that lets us assert the album mbid (captured pre-tx) is
|
||||
// passed through correctly.
|
||||
func TestRemoveTrack_Unmonitor_HappyPath(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
admin := seedAdmin(t, pool, "alice")
|
||||
|
||||
q := dbq.New(pool)
|
||||
artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
|
||||
Name: "Lidarr Artist", SortName: "Lidarr Artist",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("artist: %v", err)
|
||||
}
|
||||
albumMbid := "album-mbid-xyz"
|
||||
album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
||||
Title: "Lidarr Album", SortTitle: "Lidarr Album",
|
||||
ArtistID: artist.ID, Mbid: &albumMbid,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("album: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
pathA := filepath.Join(dir, "a.mp3")
|
||||
pathB := filepath.Join(dir, "b.mp3")
|
||||
_ = os.WriteFile(pathA, []byte("x"), 0o644)
|
||||
_ = os.WriteFile(pathB, []byte("x"), 0o644)
|
||||
trackMbid := "track-mbid-abc"
|
||||
track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||||
Title: "T1", AlbumID: album.ID, ArtistID: artist.ID,
|
||||
DurationMs: 1000, FilePath: pathA, FileSize: 1, FileFormat: "mp3",
|
||||
Mbid: &trackMbid,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("track1: %v", err)
|
||||
}
|
||||
// Sibling track keeps the album alive after the delete.
|
||||
if _, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||||
Title: "T2", AlbumID: album.ID, ArtistID: artist.ID,
|
||||
DurationMs: 1000, FilePath: pathB, FileSize: 1, FileFormat: "mp3",
|
||||
}); err != nil {
|
||||
t.Fatalf("track2: %v", err)
|
||||
}
|
||||
|
||||
fake := &fakeLidarrUnmonitorer{}
|
||||
svc := NewService(pool, nil, fake)
|
||||
_, _, lidarrFailed, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveTrack: %v", err)
|
||||
}
|
||||
if lidarrFailed {
|
||||
t.Error("lidarrUnmonitorFailed = true; want false on happy path")
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("Lidarr call count = %d, want 1", len(fake.calls))
|
||||
}
|
||||
if fake.calls[0] != trackMbid {
|
||||
t.Errorf("Lidarr called with mbid %q, want %q", fake.calls[0], trackMbid)
|
||||
}
|
||||
if fake.albumMbid != albumMbid {
|
||||
t.Errorf("Lidarr called with albumMbid %q, want %q", fake.albumMbid, albumMbid)
|
||||
}
|
||||
}
|
||||
|
||||
// unmonitor=true AND Lidarr fails → file + DB still deleted, flag set,
|
||||
// service returns nil error.
|
||||
func TestRemoveTrack_Unmonitor_LidarrErrorIsNonFatal(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
admin := seedAdmin(t, pool, "alice")
|
||||
|
||||
q := dbq.New(pool)
|
||||
artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
|
||||
Name: "ErrFatal Artist", SortName: "ErrFatal Artist",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("artist: %v", err)
|
||||
}
|
||||
albumMbid := "err-album-mbid"
|
||||
album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
||||
Title: "ErrFatal Album", SortTitle: "ErrFatal Album",
|
||||
ArtistID: artist.ID, Mbid: &albumMbid,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("album: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "f.mp3")
|
||||
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
trackMbid := "err-track-mbid"
|
||||
track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||||
Title: "T", AlbumID: album.ID, ArtistID: artist.ID,
|
||||
DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3",
|
||||
Mbid: &trackMbid,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("track: %v", err)
|
||||
}
|
||||
|
||||
fake := &fakeLidarrUnmonitorer{err: errors.New("simulated lidarr 5xx")}
|
||||
svc := NewService(pool, nil, fake)
|
||||
_, _, lidarrFailed, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveTrack: %v (want nil — Lidarr failure is non-fatal)", err)
|
||||
}
|
||||
if !lidarrFailed {
|
||||
t.Error("lidarrUnmonitorFailed = false; want true after Lidarr error")
|
||||
}
|
||||
// File + DB still gone.
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("file still present after Lidarr error: %v", err)
|
||||
}
|
||||
if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil {
|
||||
t.Errorf("track row still present after Lidarr error")
|
||||
}
|
||||
}
|
||||
|
||||
// unmonitor=true AND mbid empty (non-Lidarr track) → no Lidarr call.
|
||||
func TestRemoveTrack_Unmonitor_NoMbidSkipsLidarr(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
admin := seedAdmin(t, pool, "alice")
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "local.mp3")
|
||||
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, track := seedArtistAlbumTrack(t, pool, "Local", "", path)
|
||||
|
||||
fake := &fakeLidarrUnmonitorer{}
|
||||
svc := NewService(pool, nil, fake)
|
||||
_, _, lidarrFailed, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveTrack: %v", err)
|
||||
}
|
||||
if lidarrFailed {
|
||||
t.Error("lidarrUnmonitorFailed = true; want false (no mbid)")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Errorf("Lidarr call count = %d, want 0 (track has no mbid)", len(fake.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// unmonitor=false AND mbid set → no Lidarr call.
|
||||
func TestRemoveTrack_NoUnmonitor_SkipsLidarr(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
admin := seedAdmin(t, pool, "alice")
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "lidarr.mp3")
|
||||
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, track := seedArtistAlbumTrack(t, pool, "NoMon", "track-mbid", path)
|
||||
|
||||
fake := &fakeLidarrUnmonitorer{}
|
||||
svc := NewService(pool, nil, fake)
|
||||
_, _, lidarrFailed, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveTrack: %v", err)
|
||||
}
|
||||
if lidarrFailed {
|
||||
t.Error("lidarrUnmonitorFailed = true; want false (unmonitor=false)")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Errorf("Lidarr call count = %d, want 0 (unmonitor=false)", len(fake.calls))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user