// 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/apierror" "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 aliases apierror.ErrNotFound so handlers can errors.Is // against the shared sentinel; existing lidarrrequests.ErrNotFound // callsites still resolve to the same pointer. ErrNotFound = apierror.ErrNotFound 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) // Dedup: if a non-terminal request for this MBID already exists, // return it instead of inserting a duplicate. The Discover/search UI // already hides requested candidates; this backstops races and direct // API callers so we don't accrue duplicate request rows. Match on the // new request's own-kind MBID (mirrors HasNonTerminalRequestForMBID). dedupMBID := p.LidarrArtistMBID switch p.Kind { case "album": dedupMBID = p.LidarrAlbumMBID case "track": dedupMBID = p.LidarrTrackMBID } if existing, derr := q.GetNonTerminalRequestForMBID(ctx, dedupMBID); derr == nil { return existing, nil } else if !errors.Is(derr, pgx.ErrNoRows) { return dbq.LidarrRequest{}, fmt.Errorf("create: dedup check: %w", derr) } 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 records the admin's approval DURABLY first (snapshotting the // chosen quality profile + root folder), then makes a best-effort Lidarr // add and triggers a library scan. The add is no longer a gate: if Lidarr // is unreachable the request still becomes approved and the reconciler // idempotently (re)sends the add on each tick until it sticks (see // sendLidarrAdd + Reconciler.ensureLidarrAdd) — "Lidarr just keeps // trying" without the admin re-clicking. Pre-conditions (lidarr disabled, // not pending, defaults incomplete) still fail fast before any approval. 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 } // Record the approval durably FIRST so a Lidarr outage can't lose the // admin's decision. The snapshotted qp/rf also feed the reconciler's // retry, so a deferred add uses exactly what the admin approved with. approved, err := dbq.New(s.pool).ApproveLidarrRequest(ctx, dbq.ApproveLidarrRequestParams{ ID: requestID, QualityProfileID: int32Ptr(qp), RootFolderPath: strPtr(rf), DecidedBy: adminID, }) if err != nil { return dbq.LidarrRequest{}, fmt.Errorf("approve: persist: %w", err) } // Best-effort immediate add for the common (Lidarr-up) path. Any // failure is deliberately swallowed: the request is already durably // approved and the reconciler retries the add on its next tick, // leaving lidarr_add_confirmed_at NULL until it succeeds. if addErr := sendLidarrAdd(ctx, client, cfg, approved); addErr == nil { _ = dbq.New(s.pool).MarkLidarrRequestAddConfirmed(ctx, requestID) } s.scanFn() return approved, nil } // sendLidarrAdd performs the idempotent Lidarr add for an approved // request. Used by Service.Approve (best-effort, common path) and by the // Reconciler (retry path). qp/rf come from the request's snapshot (set at // Approve time) with a config-default fallback; the metadata profile is // resolved from config, falling back to Lidarr's first profile for // operators who upgraded from before metadata-profile support. Treats // lidarr.ErrAlreadyExists as success — the artist/album is already in // Lidarr, which is exactly the desired end state. func sendLidarrAdd(ctx context.Context, client *lidarr.Client, cfg lidarrconfig.Config, row dbq.LidarrRequest) error { qp := cfg.DefaultQualityProfileID if row.QualityProfileID != nil && *row.QualityProfileID != 0 { qp = int(*row.QualityProfileID) } rf := cfg.DefaultRootFolderPath if row.RootFolderPath != nil && *row.RootFolderPath != "" { rf = *row.RootFolderPath } if qp == 0 || rf == "" { return ErrDefaultsIncomplete } mdProfileID := cfg.DefaultMetadataProfileID if mdProfileID == 0 { mdProfiles, mdErr := client.ListMetadataProfiles(ctx) if mdErr != nil { return fmt.Errorf("list metadata profiles: %w", mdErr) } if len(mdProfiles) == 0 { return fmt.Errorf("no metadata profiles configured in lidarr") } mdProfileID = mdProfiles[0].ID } var err error 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 && !errors.Is(err, lidarr.ErrAlreadyExists) { return err } return 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 }