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>
This commit is contained in:
2026-05-18 12:24:20 -04:00
parent b76ea66165
commit e68e1b10a6
13 changed files with 382 additions and 100 deletions
+56 -11
View File
@@ -2,6 +2,7 @@ package lidarrrequests
import (
"context"
"fmt"
"log/slog"
"time"
@@ -11,16 +12,20 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
// Reconciler is a background worker that periodically scans approved
// lidarr_requests and transitions them to completed when their target
// track/album/artist has appeared in the local library (matched by MBID).
// Errors per-row are logged at WARN and do not abort the tick.
// lidarr_requests. For rows whose Lidarr add hasn't been confirmed it
// (re)sends the add idempotently until it sticks; for confirmed rows it
// transitions them to completed when their target track/album/artist has
// appeared in the local library (matched by MBID). Errors per-row are
// logged at WARN and do not abort the tick.
type Reconciler struct {
pool *pgxpool.Pool
lidarrCfg *lidarrconfig.Service
clientFn func() *lidarr.Client
logger *slog.Logger
bus *eventbus.Bus
tick time.Duration
@@ -28,12 +33,19 @@ type Reconciler struct {
}
// NewReconciler constructs a Reconciler with production defaults:
// 5-minute tick, batch size 50. Pass nil for bus when no SSE publishing
// is desired (tests, or environments without the event stream enabled).
func NewReconciler(pool *pgxpool.Pool, cfg *lidarrconfig.Service, logger *slog.Logger, bus *eventbus.Bus) *Reconciler {
// 5-minute tick, batch size 50. clientFn is the per-tick Lidarr client
// factory (same pattern as Service) so config changes apply without a
// restart; pass nil to disable the add-retry path. Pass nil for bus when
// no SSE publishing is desired (tests, or environments without the event
// stream enabled).
func NewReconciler(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client, logger *slog.Logger, bus *eventbus.Bus) *Reconciler {
if clientFn == nil {
clientFn = func() *lidarr.Client { return nil }
}
return &Reconciler{
pool: pool,
lidarrCfg: cfg,
clientFn: clientFn,
logger: logger,
bus: bus,
tick: 5 * time.Minute,
@@ -118,7 +130,7 @@ func (r *Reconciler) tickOnce(ctx context.Context) error {
}
for _, row := range rows {
if err := r.reconcileRow(ctx, q, row); err != nil {
if err := r.reconcileRow(ctx, q, cfg, row); err != nil {
r.logger.Warn("lidarrrequests: reconciler row failed",
"request_id", row.ID,
"kind", row.Kind,
@@ -129,10 +141,23 @@ func (r *Reconciler) tickOnce(ctx context.Context) error {
return nil
}
// reconcileRow checks the library for a match and, if found, calls
// CompleteLidarrRequest. It returns an error only for unexpected DB
// failures; a no-match is not an error.
func (r *Reconciler) reconcileRow(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error {
// reconcileRow drives a request forward by one step. If Lidarr hasn't
// confirmed the add yet it (re)sends it and returns — the import can't
// land before Lidarr has the add, so there's nothing to match this tick.
// Once confirmed, it checks the library for a match and, if found, calls
// CompleteLidarrRequest. Returns an error only for unexpected failures
// (the add retry's transient error included, so tickOnce logs WARN and
// it's retried next tick — "Lidarr just keeps trying"); a no-match is
// not an error.
func (r *Reconciler) reconcileRow(ctx context.Context, q *dbq.Queries, cfg lidarrconfig.Config, row dbq.LidarrRequest) error {
if !row.LidarrAddConfirmedAt.Valid {
if err := r.ensureLidarrAdd(ctx, q, cfg, row); err != nil {
return err
}
// ensureLidarrAdd either confirmed the add or no-op'd (Lidarr
// client unavailable). Fall through to import-matching so an
// item already present in the library still completes this tick.
}
switch row.Kind {
case dbq.LidarrRequestKindArtist:
return r.reconcileArtist(ctx, q, row)
@@ -146,6 +171,26 @@ func (r *Reconciler) reconcileRow(ctx context.Context, q *dbq.Queries, row dbq.L
}
}
// ensureLidarrAdd (re)sends the Lidarr add for an approved request whose
// add hasn't been confirmed. On success (including ErrAlreadyExists,
// handled inside sendLidarrAdd) it stamps lidarr_add_confirmed_at so this
// stops re-sending and the next tick moves to import-matching. A failure
// is returned so tickOnce logs WARN and the row is retried next tick;
// there is intentionally no failed-state or attempt cap — per product
// decision Lidarr just keeps trying and the operator monitors Lidarr.
func (r *Reconciler) ensureLidarrAdd(ctx context.Context, q *dbq.Queries, cfg lidarrconfig.Config, row dbq.LidarrRequest) error {
client := r.clientFn()
if client == nil {
// Lidarr disabled mid-flight; tickOnce already gates on
// cfg.Enabled, so this is just defensive — nothing to do.
return nil
}
if err := sendLidarrAdd(ctx, client, cfg, row); err != nil {
return fmt.Errorf("lidarr add retry: %w", err)
}
return q.MarkLidarrRequestAddConfirmed(ctx, row.ID)
}
func (r *Reconciler) reconcileArtist(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error {
var artistID pgtype.UUID
err := r.pool.QueryRow(ctx,