From b11fc117e7eaf4e5974c8831a7d11999e85bec61 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 17:12:03 -0400 Subject: [PATCH] feat(lidarrrequests): request lifecycle service (create/list/approve/reject/cancel) Co-Authored-By: Claude Sonnet 4.6 --- internal/lidarrrequests/service.go | 260 ++++++++++++++++++++++++ internal/lidarrrequests/service_test.go | 146 +++++++++++++ 2 files changed, 406 insertions(+) create mode 100644 internal/lidarrrequests/service.go create mode 100644 internal/lidarrrequests/service_test.go diff --git a/internal/lidarrrequests/service.go b/internal/lidarrrequests/service.go new file mode 100644 index 00000000..7a9cc0be --- /dev/null +++ b/internal/lidarrrequests/service.go @@ -0,0 +1,260 @@ +// Package lidarrrequests owns the lifecycle of user requests to add +// music via Lidarr. The synchronous Service handles Create/List/ +// Approve/Reject/Cancel; the async Reconciler (separate file) closes +// approved requests once their target track lands in the library. +package lidarrrequests + +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 error codes. +var ( + ErrInvalidKindFields = errors.New("lidarrrequests: missing required fields for kind") + ErrNotPending = errors.New("lidarrrequests: request is not pending") + ErrNotFound = errors.New("lidarrrequests: request not found") + ErrLidarrDisabled = errors.New("lidarrrequests: lidarr not configured") +) + +// CreateParams is the input for a new request from a user. +type CreateParams struct { + Kind string // "artist", "album", or "track" + LidarrArtistMBID string + LidarrAlbumMBID string // required for kind=album/track + LidarrTrackMBID string // required for kind=track + ArtistName string + AlbumTitle string // required for kind=album/track + TrackTitle string // required for kind=track +} + +// ApproveOverrides lets the admin override the snapshot defaults for one +// approval. Zero values mean "use config default." +type ApproveOverrides struct { + QualityProfileID int + RootFolderPath string +} + +// Service owns the request lifecycle. clientFn is a factory called per +// Approve so that BaseURL/APIKey changes in lidarrconfig take effect +// immediately without restarting. scanFn is called after a successful +// Approve to trigger a library scan. +type Service struct { + pool *pgxpool.Pool + lidarrCfg *lidarrconfig.Service + clientFn func() *lidarr.Client // factory, called per Approve + scanFn func() +} + +// NewService constructs a Service. Pass nil for clientFn to disable Lidarr +// (Approve returns ErrLidarrDisabled). Pass nil for scanFn to no-op. +func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client, scanFn func()) *Service { + if scanFn == nil { + scanFn = func() {} + } + if clientFn == nil { + clientFn = func() *lidarr.Client { return nil } + } + return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn, scanFn: scanFn} +} + +// Create validates the kind→required-fields invariant and inserts a +// pending row. +func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.LidarrRequest, error) { + if err := validateKindFields(p); err != nil { + return dbq.LidarrRequest{}, err + } + q := dbq.New(s.pool) + row, err := q.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{ + UserID: userID, + Kind: dbq.LidarrRequestKind(p.Kind), + LidarrArtistMbid: p.LidarrArtistMBID, + LidarrAlbumMbid: strPtr(p.LidarrAlbumMBID), + LidarrTrackMbid: strPtr(p.LidarrTrackMBID), + ArtistName: p.ArtistName, + AlbumTitle: strPtr(p.AlbumTitle), + TrackTitle: strPtr(p.TrackTitle), + }) + if err != nil { + return dbq.LidarrRequest{}, fmt.Errorf("create: %w", err) + } + return row, nil +} + +func validateKindFields(p CreateParams) error { + if p.LidarrArtistMBID == "" || p.ArtistName == "" { + return fmt.Errorf("%w: artist_mbid and artist_name are always required", ErrInvalidKindFields) + } + switch p.Kind { + case "artist": + // fine + case "album": + if p.LidarrAlbumMBID == "" || p.AlbumTitle == "" { + return fmt.Errorf("%w: album kind requires album_mbid and album_title", ErrInvalidKindFields) + } + case "track": + if p.LidarrAlbumMBID == "" || p.AlbumTitle == "" { + return fmt.Errorf("%w: track kind requires album_mbid and album_title (track will be promoted)", ErrInvalidKindFields) + } + if p.LidarrTrackMBID == "" || p.TrackTitle == "" { + return fmt.Errorf("%w: track kind requires track_mbid and track_title", ErrInvalidKindFields) + } + default: + return fmt.Errorf("%w: unknown kind %q", ErrInvalidKindFields, p.Kind) + } + return nil +} + +// ListPending returns pending requests ordered by requested_at DESC. +func (s *Service) ListPending(ctx context.Context, limit int32) ([]dbq.LidarrRequest, error) { + return dbq.New(s.pool).ListLidarrRequestsByStatus(ctx, dbq.ListLidarrRequestsByStatusParams{ + Status: dbq.LidarrRequestStatusPending, Limit: limit, + }) +} + +// ListByStatus returns requests with the given status, ordered by requested_at DESC. +func (s *Service) ListByStatus(ctx context.Context, status string, limit int32) ([]dbq.LidarrRequest, error) { + return dbq.New(s.pool).ListLidarrRequestsByStatus(ctx, dbq.ListLidarrRequestsByStatusParams{ + Status: dbq.LidarrRequestStatus(status), Limit: limit, + }) +} + +// ListForUser returns all requests by a user, ordered by requested_at DESC. +func (s *Service) ListForUser(ctx context.Context, userID pgtype.UUID, limit int32) ([]dbq.LidarrRequest, error) { + return dbq.New(s.pool).ListLidarrRequestsForUser(ctx, dbq.ListLidarrRequestsForUserParams{ + UserID: userID, Limit: limit, + }) +} + +// Approve transitions a pending request to approved, snapshotting the +// chosen quality profile + root folder, then calls Lidarr to actually +// add the artist/album, then triggers a library scan. If Lidarr returns +// an error, the request stays pending — the admin sees the error and +// can retry without losing the request. +func (s *Service) Approve(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, ov ApproveOverrides) (dbq.LidarrRequest, error) { + cfg, err := s.lidarrCfg.Get(ctx) + if err != nil { + return dbq.LidarrRequest{}, fmt.Errorf("approve: load config: %w", err) + } + client := s.clientFn() + if !cfg.Enabled || client == nil { + return dbq.LidarrRequest{}, ErrLidarrDisabled + } + row, err := dbq.New(s.pool).GetLidarrRequestByID(ctx, requestID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.LidarrRequest{}, ErrNotFound + } + return dbq.LidarrRequest{}, fmt.Errorf("approve: get: %w", err) + } + if row.Status != dbq.LidarrRequestStatusPending { + return dbq.LidarrRequest{}, ErrNotPending + } + + qp := cfg.DefaultQualityProfileID + if ov.QualityProfileID != 0 { + qp = ov.QualityProfileID + } + rf := cfg.DefaultRootFolderPath + if ov.RootFolderPath != "" { + rf = ov.RootFolderPath + } + + switch row.Kind { + case dbq.LidarrRequestKindArtist: + err = client.AddArtist(ctx, lidarr.AddArtistParams{ + ForeignArtistID: row.LidarrArtistMbid, QualityProfileID: qp, RootFolderPath: rf, MonitorAll: true, + }) + case dbq.LidarrRequestKindAlbum, dbq.LidarrRequestKindTrack: + // Track-kind requests promote to album-add; the spec is explicit. + albumMBID := "" + if row.LidarrAlbumMbid != nil { + albumMBID = *row.LidarrAlbumMbid + } + err = client.AddAlbum(ctx, lidarr.AddAlbumParams{ + ForeignAlbumID: albumMBID, ForeignArtistID: row.LidarrArtistMbid, + QualityProfileID: qp, RootFolderPath: rf, + }) + } + if err != nil { + return dbq.LidarrRequest{}, fmt.Errorf("approve: lidarr add: %w", err) + } + + approved, err := dbq.New(s.pool).ApproveLidarrRequest(ctx, dbq.ApproveLidarrRequestParams{ + ID: requestID, + QualityProfileID: int32Ptr(qp), + RootFolderPath: strPtr(rf), + DecidedBy: adminID, + }) + if err != nil { + // Lidarr accepted but our DB update failed; admin should retry. + return dbq.LidarrRequest{}, fmt.Errorf("approve: persist: %w", err) + } + s.scanFn() + return approved, nil +} + +// Reject transitions a pending request to rejected, recording notes and +// who decided. Returns ErrNotPending if the request is not pending, +// ErrNotFound if no such request exists. +func (s *Service) Reject(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, notes string) (dbq.LidarrRequest, error) { + row, err := dbq.New(s.pool).RejectLidarrRequest(ctx, dbq.RejectLidarrRequestParams{ + ID: requestID, Notes: strPtr(notes), DecidedBy: adminID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + // Either not found OR not pending — check which. + cur, gerr := dbq.New(s.pool).GetLidarrRequestByID(ctx, requestID) + if gerr != nil { + return dbq.LidarrRequest{}, ErrNotFound + } + if cur.Status != dbq.LidarrRequestStatusPending { + return dbq.LidarrRequest{}, ErrNotPending + } + return dbq.LidarrRequest{}, ErrNotFound + } + return dbq.LidarrRequest{}, fmt.Errorf("reject: %w", err) + } + return row, nil +} + +// Cancel lets a user withdraw their own pending request. The SQL WHERE +// clause enforces both ownership (user_id = $2) and pending status +// (status = 'pending'). A zero-rows result always means ErrNotPending +// from the caller's perspective. +func (s *Service) Cancel(ctx context.Context, requestID pgtype.UUID, userID pgtype.UUID) (dbq.LidarrRequest, error) { + row, err := dbq.New(s.pool).CancelLidarrRequest(ctx, dbq.CancelLidarrRequestParams{ + ID: requestID, DecidedBy: userID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.LidarrRequest{}, ErrNotPending + } + return dbq.LidarrRequest{}, fmt.Errorf("cancel: %w", err) + } + return row, nil +} + +func strPtr(s string) *string { + if s == "" { + return nil + } + return &s +} + +func int32Ptr(i int) *int32 { + if i == 0 { + return nil + } + v := int32(i) + return &v +} diff --git a/internal/lidarrrequests/service_test.go b/internal/lidarrrequests/service_test.go new file mode 100644 index 00000000..18d7aed0 --- /dev/null +++ b/internal/lidarrrequests/service_test.go @@ -0,0 +1,146 @@ +package lidarrrequests + +import ( + "context" + "errors" + "io" + "log/slog" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + "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) + if _, err := pool.Exec(context.Background(), + "DELETE FROM lidarr_requests; UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL WHERE id=1", + ); err != nil { + t.Fatalf("reset lidarr tables: %v", err) + } + return pool +} + +func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID { + t.Helper() + u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ + Username: dbtest.TestUserPrefix + "rqtester", PasswordHash: "x", ApiToken: "x", IsAdmin: false, + }) + if err != nil { + t.Fatalf("seed user: %v", err) + } + return u.ID +} + +func TestCreate_HappyPath_Artist(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + r, err := svc.Create(context.Background(), user, CreateParams{ + Kind: "artist", + LidarrArtistMBID: "069b64b6-7884-4f6a-94cc-e4c1d6c87a01", + ArtistName: "Boards of Canada", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if r.Status != dbq.LidarrRequestStatusPending { + t.Errorf("status = %v", r.Status) + } +} + +func TestCreate_TrackKindRequiresAlbumMBID(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + _, err := svc.Create(context.Background(), user, CreateParams{ + Kind: "track", + LidarrArtistMBID: "a-mbid", ArtistName: "X", + LidarrTrackMBID: "t-mbid", TrackTitle: "Y", + // missing album fields + }) + if !errors.Is(err, ErrInvalidKindFields) { + t.Fatalf("err = %v, want ErrInvalidKindFields", err) + } +} + +func TestApprove_NotConfigured(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + r, _ := svc.Create(context.Background(), user, CreateParams{ + Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", + }) + _, err := svc.Approve(context.Background(), r.ID, user, ApproveOverrides{}) + if !errors.Is(err, ErrLidarrDisabled) { + t.Fatalf("err = %v, want ErrLidarrDisabled", err) + } +} + +func TestReject_TransitionsToRejected(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + r, _ := svc.Create(context.Background(), user, CreateParams{ + Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", + }) + rejected, err := svc.Reject(context.Background(), r.ID, user, "low quality") + if err != nil { + t.Fatalf("Reject: %v", err) + } + if rejected.Status != dbq.LidarrRequestStatusRejected { + t.Errorf("status = %v", rejected.Status) + } +} + +func TestReject_AlreadyRejectedReturnsErrNotPending(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + r, _ := svc.Create(context.Background(), user, CreateParams{ + Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", + }) + _, _ = svc.Reject(context.Background(), r.ID, user, "first") + _, err := svc.Reject(context.Background(), r.ID, user, "second") + if !errors.Is(err, ErrNotPending) { + t.Fatalf("err = %v, want ErrNotPending", err) + } +} + +func TestCancel_OwnPendingOnly(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool) + svc := NewService(pool, lidarrconfig.New(pool), nil, nil) + r, _ := svc.Create(context.Background(), user, CreateParams{ + Kind: "artist", LidarrArtistMBID: "a-mbid", ArtistName: "X", + }) + if _, err := svc.Cancel(context.Background(), r.ID, user); err != nil { + t.Fatalf("Cancel: %v", err) + } + // Second cancel hits "not pending" because we just rejected it. + if _, err := svc.Cancel(context.Background(), r.ID, user); !errors.Is(err, ErrNotPending) { + t.Errorf("err = %v, want ErrNotPending", err) + } +}