Files
bvandeusen e68e1b10a6 fix(player+lidarr): mini-player sync race; durable approve + dedup
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>
2026-05-18 12:24:20 -04:00

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
}