fix: a release change Lidarr applied but did not answer is recorded (M498)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m43s
release / integration (push) Failing after 5m5s
release / android (push) Successful in 5m58s
release / Build signed APK (releases and dev) (push) Successful in 6m17s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped

On the first deploy pass Lidarr took longer than the client's 30s timeout to
answer the release change for Cracker Island: its album update unlinks the
files and queues the rescan before it replies. The change may well have
landed, but the resolver logged a failure and wrote no audit row, so the
album had no settle window and the report did not show it.

An unanswered PUT (ErrUnreachable) is now read back: when the album's
monitored release is the chosen one, the change is recorded as made. Any
other error, or a read-back that disagrees, stands as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 22:42:50 -04:00
co-authored by Claude Opus 5.5
parent e17556dce9
commit e1ad1c9ba2
2 changed files with 86 additions and 2 deletions
+23
View File
@@ -668,12 +668,35 @@ func changeRelease(
return nil, "Lidarr's release of this album lists songs twice, and no other release lists each once while keeping what is on disk.", nil return nil, "Lidarr's release of this album lists songs twice, and no other release lists each once while keeping what is on disk.", nil
} }
if err := lid.SetMonitoredRelease(ctx, la.ID, best.ID); err != nil { if err := lid.SetMonitoredRelease(ctx, la.ID, best.ID); err != nil {
// Lidarr's album update unlinks the album's files and queues the rescan
// before it answers, and on a large album that outlasts the client's
// timeout while the change still lands (Cracker Island on the first
// deploy pass). An unanswered PUT is read back: when the release did
// change, it is recorded like any other, so the album gets its settle
// window and the report shows it.
if !errors.Is(err, lidarr.ErrUnreachable) || !releaseMonitored(ctx, lid, la.ID, best.ID) {
return nil, "", err return nil, "", err
} }
}
note := fmt.Sprintf("Lidarr now monitors the %s release, which lists each song once. The extra copies are merged once Lidarr has rescanned.", releaseLabel(best)) note := fmt.Sprintf("Lidarr now monitors the %s release, which lists each song once. The extra copies are merged once Lidarr has rescanned.", releaseLabel(best))
return &ReleaseChange{AlbumTitle: a.title, ArtistName: a.artist, From: from, To: best}, note, nil return &ReleaseChange{AlbumTitle: a.title, ArtistName: a.artist, From: from, To: best}, note, nil
} }
// releaseMonitored reads whether the album's monitored release is now
// releaseID. A failed read answers false: the change is not assumed.
func releaseMonitored(ctx context.Context, lid LidarrLibrary, albumID, releaseID int) bool {
releases, err := lid.GetAlbumReleases(ctx, albumID)
if err != nil {
return false
}
for _, r := range releases {
if r.ID == releaseID {
return r.Monitored
}
}
return false
}
type releaseCandidate struct { type releaseCandidate struct {
release lidarr.AlbumRelease release lidarr.AlbumRelease
coverage int coverage int
+62 -1
View File
@@ -3,6 +3,7 @@ package library
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -83,6 +84,9 @@ type fakeLidarr struct {
releaseTracks map[int][]lidarr.ReleaseTrack releaseTracks map[int][]lidarr.ReleaseTrack
setCalls [][2]int setCalls [][2]int
searchCalls [][]int searchCalls [][]int
// setErr is what SetMonitoredRelease answers after applying the change:
// Lidarr timing out on a change that landed.
setErr error
} }
func (f *fakeLidarr) ListUnmappedTrackFiles(context.Context) ([]lidarr.TrackFile, error) { func (f *fakeLidarr) ListUnmappedTrackFiles(context.Context) ([]lidarr.TrackFile, error) {
@@ -127,7 +131,7 @@ func (f *fakeLidarr) SetMonitoredRelease(_ context.Context, albumID, releaseID i
for i := range f.releases { for i := range f.releases {
f.releases[i].Monitored = f.releases[i].ID == releaseID f.releases[i].Monitored = f.releases[i].ID == releaseID
} }
return nil return f.setErr
} }
// resolveFixture is the Humanz shape: one album, one song twice, both copies on // resolveFixture is the Humanz shape: one album, one song twice, both copies on
@@ -565,3 +569,60 @@ func TestResolveDuplicates_SearchesForASoleCopyRip_Integration(t *testing.T) {
t.Error("the suspect-sources report doesn't show the search") t.Error("the suspect-sources report doesn't show the search")
} }
} }
// Lidarr applies the release change but the PUT times out (Cracker Island on
// the first deploy pass). The change is read back and recorded, so the album
// settles; when it did not land, the error stands and nothing is recorded.
func TestResolveDuplicates_RecordsAReleaseChangeLidarrDidNotAnswer_Integration(t *testing.T) {
for name, landed := range map[string]bool{"landed": true, "did not land": false} {
t.Run(name, func(t *testing.T) {
f := newResolveFixture(t)
lid := &fakeLidarr{
album: lidarr.LidarrAlbum{ID: 4706, ForeignAlbumID: "rg-humanz", Title: "Cracker Island"},
releases: []lidarr.AlbumRelease{
{ID: 1, Format: "Digital Media", TrackCount: 4, Monitored: true},
{ID: 2, Format: "Digital Media", TrackCount: 2},
},
releaseTracks: map[int][]lidarr.ReleaseTrack{
1: rt("Saturnz Barz", "Ascension", "Saturnz Barz", "Ascension"),
2: rt("Saturnz Barz", "Ascension"),
},
setErr: fmt.Errorf("%w: context deadline exceeded", lidarr.ErrUnreachable),
}
if !landed {
lid.setErr = fmt.Errorf("%w: connection refused", lidarr.ErrUnreachable)
}
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", &undoingLidarr{fakeLidarr: lid, undo: !landed}, true)
if landed {
if err != nil || len(res.ReleaseChanges) != 1 {
t.Fatalf("changes %d (%v), want the landed change recorded", len(res.ReleaseChanges), err)
}
} else if len(res.ReleaseChanges) != 0 {
t.Fatalf("recorded %d changes that did not land", len(res.ReleaseChanges))
}
var audited int
if err := f.pool.QueryRow(context.Background(),
`SELECT count(*) FROM audit_log WHERE action = 'lidarr_release_change'`).Scan(&audited); err != nil {
t.Fatal(err)
}
if want := map[bool]int{true: 1, false: 0}[landed]; audited != want {
t.Errorf("audited %d release changes, want %d", audited, want)
}
})
}
}
// undoingLidarr is a Lidarr whose release change fails without landing when
// undo is set: the PUT never reached it.
type undoingLidarr struct {
*fakeLidarr
undo bool
}
func (u *undoingLidarr) SetMonitoredRelease(ctx context.Context, albumID, releaseID int) error {
if !u.undo {
return u.fakeLidarr.SetMonitoredRelease(ctx, albumID, releaseID)
}
u.setCalls = append(u.setCalls, [2]int{albumID, releaseID})
return u.setErr
}