feat(lidarrrequests): request lifecycle service (create/list/approve/reject/cancel)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user