e68e1b10a6
player: setQueueFromTracks fast-starts a single source at player-index 0 while the full queue is broadcast, so the transient currentIndexStream→0 emission clobbered the correct mediaItem with queue.value[0] (the first track). Mini bar / playlist marker pinned to the wrong track until a later index event (~the "passive ~30s recovery"). Track a logical-index base so the player→queue mapping stays correct during the fill window; also fixes the latent forward-fill auto-advance off-by-base. lidarr #50: approving no longer fails when Lidarr is down. Approve records the decision durably first, then best-effort adds; the reconciler idempotently (re)sends unconfirmed adds every tick until they stick (new additive lidarr_add_confirmed_at; AddArtist/AddAlbum map Lidarr's "already exists" 400 → ErrAlreadyExists). No failed-state or expiry by design — Lidarr keeps trying, operator monitors. lidarr #51: Create() is now idempotent — a non-terminal request for the same MBID returns the existing row instead of inserting a duplicate. Rewrites the obsolete LidarrUnreachable_503 test to assert the durable- approve contract; threads a client factory into NewReconciler. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
347 lines
13 KiB
Go
347 lines
13 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)
|
|
// 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
|
|
}
|