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
+81 -9
View File
@@ -20,7 +20,7 @@ UPDATE lidarr_requests
decided_by = $4,
updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
`
type ApproveLidarrRequestParams struct {
@@ -60,6 +60,7 @@ func (q *Queries) ApproveLidarrRequest(ctx context.Context, arg ApproveLidarrReq
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
@@ -72,7 +73,7 @@ UPDATE lidarr_requests
decided_by = $2,
updated_at = now()
WHERE id = $1 AND user_id = $2 AND status = 'pending'
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
`
type CancelLidarrRequestParams struct {
@@ -110,6 +111,7 @@ func (q *Queries) CancelLidarrRequest(ctx context.Context, arg CancelLidarrReque
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
@@ -123,7 +125,7 @@ UPDATE lidarr_requests
completed_at = now(),
updated_at = now()
WHERE id = $1 AND status = 'approved'
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
`
type CompleteLidarrRequestParams struct {
@@ -169,6 +171,7 @@ func (q *Queries) CompleteLidarrRequest(ctx context.Context, arg CompleteLidarrR
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
@@ -179,7 +182,7 @@ INSERT INTO lidarr_requests (
lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid,
artist_name, album_title, track_title
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
`
type CreateLidarrRequestParams struct {
@@ -227,12 +230,13 @@ func (q *Queries) CreateLidarrRequest(ctx context.Context, arg CreateLidarrReque
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
const getLidarrRequestByID = `-- name: GetLidarrRequestByID :one
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM lidarr_requests WHERE id = $1
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests WHERE id = $1
`
func (q *Queries) GetLidarrRequestByID(ctx context.Context, id pgtype.UUID) (LidarrRequest, error) {
@@ -260,6 +264,53 @@ func (q *Queries) GetLidarrRequestByID(ctx context.Context, id pgtype.UUID) (Lid
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
const getNonTerminalRequestForMBID = `-- name: GetNonTerminalRequestForMBID :one
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
WHERE status IN ('pending', 'approved', 'completed')
AND ((kind = 'artist' AND lidarr_artist_mbid = $1)
OR (kind = 'album' AND lidarr_album_mbid = $1)
OR (kind = 'track' AND lidarr_track_mbid = $1))
ORDER BY requested_at ASC
LIMIT 1
`
// Returns the oldest non-terminal (pending/approved/completed) request
// whose own-kind MBID matches @mbid, or pgx.ErrNoRows when none. Create()
// uses this to dedupe: a re-request for something already in flight
// returns the existing row instead of inserting a duplicate. MBIDs are
// globally unique per entity type, so the cross-column OR cannot
// false-match (an artist MBID never collides with an album/track MBID).
func (q *Queries) GetNonTerminalRequestForMBID(ctx context.Context, mbid string) (LidarrRequest, error) {
row := q.db.QueryRow(ctx, getNonTerminalRequestForMBID, mbid)
var i LidarrRequest
err := row.Scan(
&i.ID,
&i.UserID,
&i.Status,
&i.Kind,
&i.LidarrArtistMbid,
&i.LidarrAlbumMbid,
&i.LidarrTrackMbid,
&i.ArtistName,
&i.AlbumTitle,
&i.TrackTitle,
&i.QualityProfileID,
&i.RootFolderPath,
&i.DecidedAt,
&i.DecidedBy,
&i.Notes,
&i.CompletedAt,
&i.MatchedTrackID,
&i.MatchedAlbumID,
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}
@@ -287,7 +338,7 @@ func (q *Queries) HasNonTerminalRequestForMBID(ctx context.Context, mbid string)
}
const listApprovedLidarrRequestsForReconcile = `-- name: ListApprovedLidarrRequestsForReconcile :many
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM lidarr_requests
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
WHERE status = 'approved'
ORDER BY decided_at ASC
LIMIT $1
@@ -324,6 +375,7 @@ func (q *Queries) ListApprovedLidarrRequestsForReconcile(ctx context.Context, li
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
); err != nil {
return nil, err
}
@@ -336,7 +388,7 @@ func (q *Queries) ListApprovedLidarrRequestsForReconcile(ctx context.Context, li
}
const listLidarrRequestsByStatus = `-- name: ListLidarrRequestsByStatus :many
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM lidarr_requests
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
WHERE status = $1
ORDER BY requested_at DESC
LIMIT $2
@@ -378,6 +430,7 @@ func (q *Queries) ListLidarrRequestsByStatus(ctx context.Context, arg ListLidarr
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
); err != nil {
return nil, err
}
@@ -390,7 +443,7 @@ func (q *Queries) ListLidarrRequestsByStatus(ctx context.Context, arg ListLidarr
}
const listLidarrRequestsForUser = `-- name: ListLidarrRequestsForUser :many
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM lidarr_requests
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
WHERE user_id = $1
ORDER BY requested_at DESC
LIMIT $2
@@ -432,6 +485,7 @@ func (q *Queries) ListLidarrRequestsForUser(ctx context.Context, arg ListLidarrR
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
); err != nil {
return nil, err
}
@@ -443,6 +497,23 @@ func (q *Queries) ListLidarrRequestsForUser(ctx context.Context, arg ListLidarrR
return items, nil
}
const markLidarrRequestAddConfirmed = `-- name: MarkLidarrRequestAddConfirmed :exec
UPDATE lidarr_requests
SET lidarr_add_confirmed_at = now(),
updated_at = now()
WHERE id = $1
AND status = 'approved'
AND lidarr_add_confirmed_at IS NULL
`
// Idempotently records that Lidarr accepted the add for an approved
// request. The lidarr_add_confirmed_at IS NULL guard makes a redundant
// reconciler retry a no-op rather than bumping updated_at every tick.
func (q *Queries) MarkLidarrRequestAddConfirmed(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, markLidarrRequestAddConfirmed, id)
return err
}
const rejectLidarrRequest = `-- name: RejectLidarrRequest :one
UPDATE lidarr_requests
SET status = 'rejected',
@@ -451,7 +522,7 @@ UPDATE lidarr_requests
decided_by = $3,
updated_at = now()
WHERE id = $1 AND status = 'pending'
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
`
type RejectLidarrRequestParams struct {
@@ -485,6 +556,7 @@ func (q *Queries) RejectLidarrRequest(ctx context.Context, arg RejectLidarrReque
&i.MatchedArtistID,
&i.RequestedAt,
&i.UpdatedAt,
&i.LidarrAddConfirmedAt,
)
return i, err
}