ab8235dd0b
The "I can't approve a request, the toast just says unknown" bug was
four problems compounded:
1. Lidarr config let enabled=true save with default_quality_profile_id=0
and default_root_folder_path=''. Approve then sent invalid POST bodies
to Lidarr, which 5xx'd.
2. lidarr.client.post() discarded Lidarr's response body on error, so
we couldn't tell why Lidarr 5xx'd from server logs.
3. handleApproveRequest's error switch didn't map ErrServerError or
ErrLookupFailed — both fell through to a generic 500 server_error.
4. apiFetch only parsed {error: {code, message}} envelopes, but admin
endpoints write {error: 'code_string'}. Every admin error toast
rendered as 'unknown'.
Fixes:
- internal/lidarr/client.go: capture up to 512 bytes of Lidarr's response
body when it returns 4xx/5xx; include in the wrapped error so server
logs show what Lidarr actually said instead of just the status bucket.
- internal/lidarrrequests/service.go: new ErrDefaultsIncomplete fires
before the Lidarr call when QP=0 or root_folder=''. Stops the bad
POST entirely.
- internal/api/admin_requests.go: handleApproveRequest now maps
ErrDefaultsIncomplete -> 'lidarr_defaults_incomplete' (400),
ErrServerError -> 'lidarr_server_error' (502),
ErrLookupFailed -> 'lidarr_rejected' (502).
- internal/api/admin_lidarr.go: handlePutLidarrConfig now requires
QP + root folder to be set whenever enabled=true.
- web/src/lib/api/client.ts: apiFetch handles both error envelope shapes
so admin error codes propagate to toasts.
- web/src/routes/admin/integrations/+page.svelte: auto-default to the
first quality profile and first root folder Lidarr returns when the
operator hasn't picked one yet — saves a click for typical
one-profile/one-folder home setups.
- web/src/routes/admin/requests/+page.svelte: friendly toast copy for
the new error codes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
270 lines
9.6 KiB
Go
270 lines
9.6 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/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
|
|
}
|
|
|
|
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
|
|
}
|