diff --git a/internal/lidarrquarantine/service.go b/internal/lidarrquarantine/service.go index b13343b6..571f6b62 100644 --- a/internal/lidarrquarantine/service.go +++ b/internal/lidarrquarantine/service.go @@ -1,8 +1,7 @@ // Package lidarrquarantine owns the per-user track quarantine workflow. // Users flag a track as broken (Flag/Unflag), the SPA hides the track // from their views, and admins resolve the resulting reports via the -// Service's admin actions (Resolve / DeleteFile / DeleteViaLidarr — -// the admin action methods are added in a follow-up task). +// Service's admin actions (Resolve / DeleteFile / DeleteViaLidarr). package lidarrquarantine import ( @@ -17,6 +16,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/library" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" ) @@ -179,3 +179,172 @@ func validReason(r string) bool { } return false } + +// Resolve clears all per-user quarantine rows for a track and writes an +// audit log row. Idempotent — a track with no rows still writes an audit +// entry with affected_users=0 (so admin can see "I clicked resolve on a +// track that already had no reports"). +func (s *Service) Resolve(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) { + q := dbq.New(s.pool) + track, err := q.GetTrackByID(ctx, trackID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.LidarrQuarantineAction{}, ErrTrackNotFound + } + return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err) + } + snap, err := s.snapshot(ctx, q, track) + if err != nil { + return dbq.LidarrQuarantineAction{}, err + } + + affected, err := q.CountQuarantineForTrack(ctx, trackID) + if err != nil { + return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err) + } + if err := q.DeleteQuarantineForTrack(ctx, trackID); err != nil { + return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete rows: %w", err) + } + return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ + TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, + AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindResolved, + AdminID: adminID, LidarrAlbumMbid: snap.LidarrAlbumMBID, AffectedUsers: affected, + }) +} + +// quarantineSnapshot is the audit-row-ready snapshot of track + album + +// artist text columns. Used by the three admin actions. +type quarantineSnapshot struct { + TrackTitle string + ArtistName string + AlbumTitle *string + LidarrAlbumMBID *string +} + +// snapshot pulls the audit-row text columns from track / album / artist. +// AlbumTitle is materialized as *string because WriteQuarantineActionParams +// expects nullable; we coerce the schema's NOT NULL value into a pointer. +func (s *Service) snapshot(ctx context.Context, q *dbq.Queries, track dbq.Track) (quarantineSnapshot, error) { + album, err := q.GetAlbumByID(ctx, track.AlbumID) + if err != nil { + return quarantineSnapshot{}, fmt.Errorf("get album: %w", err) + } + artist, err := q.GetArtistByID(ctx, track.ArtistID) + if err != nil { + return quarantineSnapshot{}, fmt.Errorf("get artist: %w", err) + } + titlePtr := album.Title + return quarantineSnapshot{ + TrackTitle: track.Title, + ArtistName: artist.Name, + AlbumTitle: &titlePtr, + LidarrAlbumMBID: album.Mbid, + }, nil +} + +// DeleteFile removes the track file from disk and the tracks row, then +// (via FK ON DELETE CASCADE) clears all per-user quarantine rows for that +// track and writes an audit row. If the file deletion fails, the per-user +// rows stay so admin can retry. No partial state. +func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) { + q := dbq.New(s.pool) + track, err := q.GetTrackByID(ctx, trackID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.LidarrQuarantineAction{}, ErrTrackNotFound + } + return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err) + } + snap, err := s.snapshot(ctx, q, track) + if err != nil { + return dbq.LidarrQuarantineAction{}, err + } + + affected, err := q.CountQuarantineForTrack(ctx, trackID) + if err != nil { + return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err) + } + if err := library.DeleteTrackFile(ctx, s.pool, trackID); err != nil { + return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete file: %w", err) + } + // tracks row is gone; ON DELETE CASCADE on lidarr_quarantine.track_id + // already cleared the per-user rows. We don't call + // DeleteQuarantineForTrack separately. + return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ + TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, + AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindDeletedFile, + AdminID: adminID, LidarrAlbumMbid: snap.LidarrAlbumMBID, AffectedUsers: affected, + }) +} + +// DeleteViaLidarr is the destructive admin path: tells Lidarr to remove +// the parent album with deleteFiles=true + addImportListExclusion=true, +// then removes Minstrel rows for all tracks of that album. The cascade +// on lidarr_quarantine.track_id clears per-user rows automatically. +// +// On Lidarr failure (unreachable, auth-failed, lookup-empty), nothing +// changes locally. Admin retries. +// +// Returns the audit row + the count of tracks deleted from Minstrel. +func (s *Service) DeleteViaLidarr(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, int, error) { + cfg, err := s.lidarrCfg.Get(ctx) + if err != nil { + return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("load config: %w", err) + } + client := s.clientFn() + if !cfg.Enabled || client == nil { + return dbq.LidarrQuarantineAction{}, 0, ErrLidarrDisabled + } + + q := dbq.New(s.pool) + track, err := q.GetTrackByID(ctx, trackID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.LidarrQuarantineAction{}, 0, ErrTrackNotFound + } + return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("get track: %w", err) + } + snap, err := s.snapshot(ctx, q, track) + if err != nil { + return dbq.LidarrQuarantineAction{}, 0, err + } + if snap.LidarrAlbumMBID == nil || *snap.LidarrAlbumMBID == "" { + return dbq.LidarrQuarantineAction{}, 0, ErrAlbumMBIDMissing + } + + affected, err := q.CountQuarantineForTrack(ctx, trackID) + if err != nil { + return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("count: %w", err) + } + + // Lidarr lookup: MBID -> internal album ID. + album, err := client.LookupAlbumByMBID(ctx, *snap.LidarrAlbumMBID) + if err != nil { + if errors.Is(err, lidarr.ErrNotFound) { + return dbq.LidarrQuarantineAction{}, 0, ErrLidarrAlbumNotFound + } + return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr lookup: %w", err) + } + + // Lidarr DELETE — both flags true. + if err := client.DeleteAlbum(ctx, album.ID, true, true); err != nil { + return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr delete: %w", err) + } + + // Now remove the local rows. CASCADE on lidarr_quarantine.track_id + // handles per-user rows. + res, err := s.pool.Exec(ctx, "DELETE FROM tracks WHERE album_id = $1", track.AlbumID) + if err != nil { + // Lidarr is already done; we couldn't update locally. Operator- + // recoverable: re-running picks up where we left off. + return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("delete tracks: %w", err) + } + deletedCount := int(res.RowsAffected()) + + action, err := q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ + TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, + AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindDeletedViaLidarr, + AdminID: adminID, LidarrAlbumMbid: snap.LidarrAlbumMBID, AffectedUsers: affected, + }) + return action, deletedCount, err +} diff --git a/internal/lidarrquarantine/service_test.go b/internal/lidarrquarantine/service_test.go index 8d6b4ba7..903aac86 100644 --- a/internal/lidarrquarantine/service_test.go +++ b/internal/lidarrquarantine/service_test.go @@ -5,6 +5,8 @@ import ( "errors" "io" "log/slog" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -15,6 +17,7 @@ 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" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" ) @@ -225,3 +228,238 @@ func TestListAdminQueue_AggregatesByTrackWithReasonCounts(t *testing.T) { t.Errorf("reports len = %d, want 3", len(r.Reports)) } } + +func TestResolve_ClearsRowsAndWritesAudit(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + bob := seedUser(t, pool, "bob") + track, _, _ := seedTrack(t, pool, "T", "x") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + if _, err := svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", ""); err != nil { + t.Fatalf("alice flag: %v", err) + } + if _, err := svc.Flag(context.Background(), bob.ID, track.ID, "wrong_tags", ""); err != nil { + t.Fatalf("bob flag: %v", err) + } + + audit, err := svc.Resolve(context.Background(), track.ID, alice.ID) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if audit.AffectedUsers != 2 { + t.Errorf("affected_users = %d, want 2", audit.AffectedUsers) + } + if audit.Action != dbq.LidarrQuarantineActionKindResolved { + t.Errorf("action = %v, want resolved", audit.Action) + } + n, _ := dbq.New(pool).CountQuarantineForTrack(context.Background(), track.ID) + if n != 0 { + t.Errorf("rows after resolve = %d, want 0", n) + } +} + +func TestResolve_TrackNotFound(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + var bogus pgtype.UUID + bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + bogus.Valid = true + + svc := NewService(pool, lidarrconfig.New(pool), nil) + _, err := svc.Resolve(context.Background(), bogus, user.ID) + if !errors.Is(err, ErrTrackNotFound) { + t.Errorf("err = %v, want ErrTrackNotFound", err) + } +} + +func TestDeleteFile_RemovesFileAndAuditsAffected(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + q := dbq.New(pool) + + dir := t.TempDir() + path := filepath.Join(dir, "track.mp3") + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"}) + album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "Al", SortTitle: "Al", ArtistID: artist.ID}) + track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "T", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3", + }) + + svc := NewService(pool, lidarrconfig.New(pool), nil) + if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil { + t.Fatalf("Flag: %v", err) + } + + audit, err := svc.DeleteFile(context.Background(), track.ID, user.ID) + if err != nil { + t.Fatalf("DeleteFile: %v", err) + } + if audit.AffectedUsers != 1 { + t.Errorf("affected_users = %d, want 1", audit.AffectedUsers) + } + if audit.Action != dbq.LidarrQuarantineActionKindDeletedFile { + t.Errorf("action = %v, want deleted_file", audit.Action) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Errorf("file still exists: %v", err) + } + // Track row gone; CASCADE cleared the quarantine row. + if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { + t.Errorf("track row still exists") + } +} + +func TestDeleteViaLidarr_FullCascade(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + + var captured []string + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = append(captured, r.Method+" "+r.URL.Path+"?"+r.URL.RawQuery) + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/v1/album" && r.Method == http.MethodGet { + _, _ = w.Write([]byte(`[{"id":42,"foreignAlbumId":"al-mbid","title":"Al","artistId":7}]`)) + return + } + // DELETE /api/v1/album/42 -> 200. + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(stub.Close) + + cfg := lidarrconfig.New(pool) + if err := cfg.Save(context.Background(), lidarrconfig.Config{ + Enabled: true, BaseURL: stub.URL, APIKey: "k", + }); err != nil { + t.Fatalf("save config: %v", err) + } + clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") } + svc := NewService(pool, cfg, clientFn) + + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"}) + albumMBID := "al-mbid" + album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Al", SortTitle: "Al", ArtistID: artist.ID, Mbid: &albumMBID, + }) + dir := t.TempDir() + path := filepath.Join(dir, "T.mp3") + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "T", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3", + }) + if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil { + t.Fatalf("Flag: %v", err) + } + + audit, deleted, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID) + if err != nil { + t.Fatalf("DeleteViaLidarr: %v", err) + } + if deleted != 1 { + t.Errorf("deleted = %d, want 1 track removed", deleted) + } + if audit.Action != dbq.LidarrQuarantineActionKindDeletedViaLidarr { + t.Errorf("action = %v", audit.Action) + } + if audit.AffectedUsers != 1 { + t.Errorf("affected_users = %d, want 1", audit.AffectedUsers) + } + if audit.LidarrAlbumMbid == nil || *audit.LidarrAlbumMbid != "al-mbid" { + t.Errorf("lidarr_album_mbid = %v", audit.LidarrAlbumMbid) + } + if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { + t.Errorf("track row still exists") + } + // Verify Lidarr received DELETE with both flags true (query order may vary). + foundDelete := false + for _, c := range captured { + if c == "DELETE /api/v1/album/42?addImportListExclusion=true&deleteFiles=true" || + c == "DELETE /api/v1/album/42?deleteFiles=true&addImportListExclusion=true" { + foundDelete = true + } + } + if !foundDelete { + t.Errorf("Lidarr DELETE not called with both flags true; captured = %v", captured) + } +} + +func TestDeleteViaLidarr_LidarrDisabled(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + track, _, _ := seedTrack(t, pool, "T", "x") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + _, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID) + if !errors.Is(err, ErrLidarrDisabled) { + t.Errorf("err = %v, want ErrLidarrDisabled", err) + } +} + +func TestDeleteViaLidarr_AlbumMBIDMissing(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + + q := dbq.New(pool) + artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"}) + // Album with NO mbid set. + album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Al", SortTitle: "Al", ArtistID: artist.ID, // Mbid: nil + }) + dir := t.TempDir() + path := filepath.Join(dir, "T.mp3") + _ = os.WriteFile(path, []byte("x"), 0o644) + track, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "T", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: path, FileSize: 1, FileFormat: "mp3", + }) + + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(stub.Close) + cfg := lidarrconfig.New(pool) + _ = cfg.Save(context.Background(), lidarrconfig.Config{Enabled: true, BaseURL: stub.URL, APIKey: "k"}) + clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") } + svc := NewService(pool, cfg, clientFn) + _, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "") + + _, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID) + if !errors.Is(err, ErrAlbumMBIDMissing) { + t.Errorf("err = %v, want ErrAlbumMBIDMissing", err) + } +} + +func TestDeleteViaLidarr_LidarrAlbumNotFound(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/v1/album" && r.Method == http.MethodGet { + _, _ = w.Write([]byte("[]")) // Lidarr returns empty -> ErrNotFound + return + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(stub.Close) + cfg := lidarrconfig.New(pool) + _ = cfg.Save(context.Background(), lidarrconfig.Config{Enabled: true, BaseURL: stub.URL, APIKey: "k"}) + clientFn := func() *lidarr.Client { return lidarr.NewClient(stub.URL, "k") } + svc := NewService(pool, cfg, clientFn) + + track, _, _ := seedTrack(t, pool, "T", "x") + _, _ = svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "") + + _, _, err := svc.DeleteViaLidarr(context.Background(), track.ID, user.ID) + if !errors.Is(err, ErrLidarrAlbumNotFound) { + t.Errorf("err = %v, want ErrLidarrAlbumNotFound", err) + } +}