300 lines
11 KiB
Go
300 lines
11 KiB
Go
// 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)
|
|
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
|
|
}
|