// 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") // ErrDefaultsIncomplete fires when Lidarr is enabled but the operator // hasn't picked a default quality profile or root folder. Without those, // Lidarr's add-artist/add-album endpoints reject the payload with a // generic 5xx — surfacing a typed error here lets the admin UI tell the // operator exactly what to fix. ErrDefaultsIncomplete = errors.New("lidarrrequests: default quality profile or root folder 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 } if qp == 0 || rf == "" { return dbq.LidarrRequest{}, ErrDefaultsIncomplete } // Lidarr requires a metadata profile id on POST /api/v1/artist (and // on /api/v1/album when it has to create the parent artist). Prefer // the operator's pinned choice from cfg; fall back to fetching the // list and using the first when nothing's been saved yet (operators // upgrading from before metadata-profile support landed). mdProfileID := cfg.DefaultMetadataProfileID if mdProfileID == 0 { mdProfiles, mdErr := client.ListMetadataProfiles(ctx) if mdErr != nil { return dbq.LidarrRequest{}, fmt.Errorf("approve: list metadata profiles: %w", mdErr) } if len(mdProfiles) == 0 { return dbq.LidarrRequest{}, fmt.Errorf("approve: no metadata profiles configured in lidarr") } mdProfileID = mdProfiles[0].ID } switch row.Kind { case dbq.LidarrRequestKindArtist: err = client.AddArtist(ctx, lidarr.AddArtistParams{ ForeignArtistID: row.LidarrArtistMbid, ArtistName: row.ArtistName, QualityProfileID: qp, MetadataProfileID: mdProfileID, 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, ArtistName: row.ArtistName, QualityProfileID: qp, MetadataProfileID: mdProfileID, 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 }