diff --git a/internal/lidarrquarantine/service.go b/internal/lidarrquarantine/service.go new file mode 100644 index 00000000..e69cb001 --- /dev/null +++ b/internal/lidarrquarantine/service.go @@ -0,0 +1,179 @@ +// 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). +package lidarrquarantine + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" +) + +// Public errors. Handlers map these to API codes. +var ( + ErrBadReason = errors.New("lidarrquarantine: invalid reason") + ErrTrackNotFound = errors.New("lidarrquarantine: track not found") + ErrQuarantineNotFound = errors.New("lidarrquarantine: quarantine row not found") + ErrAlbumMBIDMissing = errors.New("lidarrquarantine: track has no parent album mbid") + ErrLidarrAlbumNotFound = errors.New("lidarrquarantine: lidarr has no album for that mbid") + ErrLidarrDisabled = errors.New("lidarrquarantine: lidarr is not configured") +) + +// Service owns the request lifecycle. clientFn is a per-call factory so +// config changes in lidarrconfig take effect immediately. clientFn returns +// nil when Lidarr is disabled. +type Service struct { + pool *pgxpool.Pool + lidarrCfg *lidarrconfig.Service + clientFn func() *lidarr.Client +} + +// NewService constructs a Service. Pass nil for clientFn to disable the +// Lidarr-using methods (DeleteViaLidarr will return ErrLidarrDisabled). +func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client) *Service { + if clientFn == nil { + clientFn = func() *lidarr.Client { return nil } + } + return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn} +} + +// Flag inserts or updates a quarantine row for the caller. Re-flagging +// the same (user, track) overwrites reason+notes (and bumps created_at). +func (s *Service) Flag(ctx context.Context, userID, trackID pgtype.UUID, reason string, notes string) (dbq.LidarrQuarantine, error) { + if !validReason(reason) { + return dbq.LidarrQuarantine{}, ErrBadReason + } + var notesPtr *string + if notes != "" { + notesPtr = ¬es + } + row, err := dbq.New(s.pool).UpsertQuarantine(ctx, dbq.UpsertQuarantineParams{ + UserID: userID, + TrackID: trackID, + Reason: dbq.LidarrQuarantineReason(reason), + Notes: notesPtr, + }) + if err != nil { + // FK violation on track_id surfaces here as a postgres error; + // callers can treat any error as "track doesn't exist or DB issue." + // Distinguishing FK violation specifically isn't worth the extra + // dependency on pgconn just for one branch. + return dbq.LidarrQuarantine{}, fmt.Errorf("upsert: %w", err) + } + return row, nil +} + +// Unflag removes the caller's row. Returns ErrQuarantineNotFound if no +// row exists for that (user, track). +func (s *Service) Unflag(ctx context.Context, userID, trackID pgtype.UUID) error { + _, err := dbq.New(s.pool).DeleteQuarantine(ctx, dbq.DeleteQuarantineParams{ + UserID: userID, TrackID: trackID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrQuarantineNotFound + } + return fmt.Errorf("delete: %w", err) + } + return nil +} + +// ListMine returns the caller's quarantines with track + album + artist +// detail. Drives /library/hidden. Ordered newest-first. +func (s *Service) ListMine(ctx context.Context, userID pgtype.UUID) ([]dbq.ListQuarantineForUserRow, error) { + return dbq.New(s.pool).ListQuarantineForUser(ctx, userID) +} + +// AdminQueueRow is the assembled aggregated row served by the admin +// queue endpoint. The handler post-processes the SQL result + a per-track +// ListQuarantineReports call to materialize reason_counts and the +// per-user reports list. +type AdminQueueRow struct { + TrackID pgtype.UUID + TrackTitle string + ArtistName string + AlbumTitle *string + AlbumID pgtype.UUID + LidarrAlbumMBID *string + ReportCount int32 + LatestAt pgtype.Timestamptz + ReasonCounts map[string]int + Reports []UserReport +} + +// UserReport is the per-user detail under an aggregated admin row. +type UserReport struct { + UserID pgtype.UUID + Username string + Reason string + Notes *string + CreatedAt pgtype.Timestamptz +} + +// ListAdminQueue returns the aggregated admin queue. One row per track. +// Performs an N+1 (one ListQuarantineReportsForTrack per aggregated row) +// because materializing reason_counts in pure SQL is awkward; the queue +// is small in practice (≲100 tracks at household scale). Revisit with +// json_object_agg if the queue grows. +func (s *Service) ListAdminQueue(ctx context.Context) ([]AdminQueueRow, error) { + q := dbq.New(s.pool) + aggregated, err := q.ListAdminQuarantineQueue(ctx) + if err != nil { + return nil, fmt.Errorf("aggregate: %w", err) + } + out := make([]AdminQueueRow, 0, len(aggregated)) + for _, r := range aggregated { + reports, err := q.ListQuarantineReportsForTrack(ctx, r.TrackID) + if err != nil { + return nil, fmt.Errorf("reports for track %v: %w", r.TrackID, err) + } + rc := make(map[string]int, len(reports)) + userReports := make([]UserReport, 0, len(reports)) + for _, rep := range reports { + rc[string(rep.Reason)]++ + userReports = append(userReports, UserReport{ + UserID: rep.UserID, + Username: rep.Username, + Reason: string(rep.Reason), + Notes: rep.Notes, + CreatedAt: rep.CreatedAt, + }) + } + var albumTitle *string + if r.AlbumTitle != "" { + t := r.AlbumTitle + albumTitle = &t + } + out = append(out, AdminQueueRow{ + TrackID: r.TrackID, + TrackTitle: r.TrackTitle, + ArtistName: r.ArtistName, + AlbumTitle: albumTitle, + AlbumID: r.AlbumID, + LidarrAlbumMBID: r.LidarrAlbumMbid, + ReportCount: r.ReportCount, + LatestAt: r.LatestAt, + ReasonCounts: rc, + Reports: userReports, + }) + } + return out, nil +} + +func validReason(r string) bool { + switch r { + case "bad_rip", "wrong_file", "wrong_tags", "duplicate", "other": + return true + } + return false +} diff --git a/internal/lidarrquarantine/service_test.go b/internal/lidarrquarantine/service_test.go new file mode 100644 index 00000000..57862d72 --- /dev/null +++ b/internal/lidarrquarantine/service_test.go @@ -0,0 +1,211 @@ +package lidarrquarantine + +import ( + "context" + "errors" + "io" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "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/lidarrconfig" +) + +func newPool(t *testing.T) *pgxpool.Pool { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + dbtest.ResetDB(t, pool) + return pool +} + +func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { + t.Helper() + u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ + Username: dbtest.TestUserPrefix + name, PasswordHash: "x", + ApiToken: name + "-token", IsAdmin: false, + }) + if err != nil { + t.Fatalf("seed user %s: %v", name, err) + } + return u +} + +func seedTrack(t *testing.T, pool *pgxpool.Pool, title, mbid string) (dbq.Track, dbq.Album, dbq.Artist) { + t.Helper() + q := dbq.New(pool) + artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "Test Artist", SortName: "Test Artist", + }) + if err != nil { + t.Fatalf("artist: %v", err) + } + albumMBID := mbid + "-album" + album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Test Album", SortTitle: "Test Album", + ArtistID: artist.ID, Mbid: &albumMBID, + }) + if err != nil { + t.Fatalf("album: %v", err) + } + track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: title, AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: filepath.Join(t.TempDir(), title+".mp3"), + FileSize: 100, FileFormat: "mp3", + }) + if err != nil { + t.Fatalf("track: %v", err) + } + return track, album, artist +} + +func TestFlag_HappyPath(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + track, _, _ := seedTrack(t, pool, "Bad Track", "abc") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + row, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "crackly") + if err != nil { + t.Fatalf("Flag: %v", err) + } + if string(row.Reason) != "bad_rip" { + t.Errorf("reason = %v", row.Reason) + } + if row.Notes == nil || *row.Notes != "crackly" { + t.Errorf("notes = %v", row.Notes) + } +} + +func TestFlag_UpsertOnSecondFlag(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + track, _, _ := seedTrack(t, pool, "T", "x") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "first"); err != nil { + t.Fatalf("first flag: %v", err) + } + row, err := svc.Flag(context.Background(), user.ID, track.ID, "wrong_tags", "") + if err != nil { + t.Fatalf("second flag: %v", err) + } + if string(row.Reason) != "wrong_tags" { + t.Errorf("reason = %v", row.Reason) + } + if row.Notes != nil { + t.Errorf("notes = %v, want nil after empty notes upsert", row.Notes) + } +} + +func TestFlag_BadReasonRejected(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.Flag(context.Background(), user.ID, track.ID, "garbage", "") + if !errors.Is(err, ErrBadReason) { + t.Errorf("err = %v, want ErrBadReason", err) + } +} + +func TestUnflag_DeletesRow(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + track, _, _ := seedTrack(t, pool, "T", "x") + + 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) + } + if err := svc.Unflag(context.Background(), user.ID, track.ID); err != nil { + t.Fatalf("Unflag: %v", err) + } + if err := svc.Unflag(context.Background(), user.ID, track.ID); !errors.Is(err, ErrQuarantineNotFound) { + t.Errorf("second Unflag err = %v, want ErrQuarantineNotFound", err) + } +} + +func TestListMine_OrderedNewestFirst(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + t1, _, _ := seedTrack(t, pool, "T1", "x") + t2, _, _ := seedTrack(t, pool, "T2", "y") + + svc := NewService(pool, lidarrconfig.New(pool), nil) + if _, err := svc.Flag(context.Background(), user.ID, t1.ID, "bad_rip", ""); err != nil { + t.Fatalf("Flag t1: %v", err) + } + if _, err := svc.Flag(context.Background(), user.ID, t2.ID, "duplicate", ""); err != nil { + t.Fatalf("Flag t2: %v", err) + } + + rows, err := svc.ListMine(context.Background(), user.ID) + if err != nil { + t.Fatalf("ListMine: %v", err) + } + if len(rows) != 2 { + t.Fatalf("len = %d, want 2", len(rows)) + } + // T2 was flagged second — newest first. + if rows[0].LidarrQuarantine.TrackID != t2.ID { + t.Errorf("first row track = %v, want T2 (%v)", rows[0].LidarrQuarantine.TrackID, t2.ID) + } +} + +func TestListAdminQueue_AggregatesByTrackWithReasonCounts(t *testing.T) { + pool := newPool(t) + alice := seedUser(t, pool, "alice") + bob := seedUser(t, pool, "bob") + carol := seedUser(t, pool, "carol") + track, _, _ := seedTrack(t, pool, "Hot Mess", "abc") + + 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, "bad_rip", ""); err != nil { + t.Fatalf("bob flag: %v", err) + } + if _, err := svc.Flag(context.Background(), carol.ID, track.ID, "wrong_tags", ""); err != nil { + t.Fatalf("carol flag: %v", err) + } + + rows, err := svc.ListAdminQueue(context.Background()) + if err != nil { + t.Fatalf("ListAdminQueue: %v", err) + } + if len(rows) != 1 { + t.Fatalf("len = %d, want 1 aggregated row", len(rows)) + } + r := rows[0] + if r.ReportCount != 3 { + t.Errorf("report_count = %d, want 3", r.ReportCount) + } + if r.ReasonCounts["bad_rip"] != 2 || r.ReasonCounts["wrong_tags"] != 1 { + t.Errorf("reason_counts = %+v, want bad_rip=2 wrong_tags=1", r.ReasonCounts) + } + if len(r.Reports) != 3 { + t.Errorf("reports len = %d, want 3", len(r.Reports)) + } +}