Files
minstrel/internal/lidarrrequests/reconciler_release_group_test.go
T
bvandeusenandClaude Opus 5.5 e509d7d5a9
release / go (push) Successful in 2m33s
release / web (push) Successful in 1m41s
release / govulncheck (push) Successful in 22s
release / integration (push) Successful in 6m0s
release / android (push) Successful in 6m7s
release / Build signed APK (releases and dev) (push) Successful in 6m8s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m43s
release / Verify release artifacts (tag releases only) (push) Skipped
feat(lidarr): ask Lidarr for release groups; repair stored release-id requests (M483 #5244)
Lidarr's metadata is keyed by MusicBrainz release group, but re-acquisition
requested albums by their release id, so every add came back "not found".

- Sweeper requests an album by its tag-supplied release group, else the one
  MusicBrainz names (cached onto the album). An album MusicBrainz cannot name
  is skipped and counted, with no attempt spent.
- Reconciler: an add refused as not found re-reads the request's album id as
  a release (library first, then MusicBrainz), rewrites the request to the
  group and adds again. This repairs the requests already stored.
- Completion matches an album by release id or release group, and only once a
  track of it is on disk, so a re-acquisition request no longer completes
  against the row of the album it is trying to bring back.

Closes #5241.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:38:45 -04:00

204 lines
7.1 KiB
Go

package lidarrrequests
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// #5241: Lidarr names albums by MusicBrainz release group. A request carrying
// a group id completes against the album row whose tags named that group,
// once a track of it is on disk.
func TestReconciler_AlbumMatchesByReleaseGroup(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
const artistMBID, release, group = "rg-artist-match", "rg-release-match", "rg-group-match"
artist := seedArtist(t, q, "RG Artist", artistMBID)
album := seedAlbum(t, q, artist.ID, "RG Album", release)
if err := q.SetAlbumReleaseGroupMbidIfNull(ctx, dbq.SetAlbumReleaseGroupMbidIfNullParams{
ID: album.ID, ReleaseGroupMbid: nilableStr(group),
}); err != nil {
t.Fatal(err)
}
_ = seedTrack(t, q, album.ID, artist.ID, "RG One", "/music/rg-match/01.flac")
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "RG Artist",
LidarrAlbumMBID: group, AlbumTitle: "RG Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != dbq.LidarrRequestStatusCompleted || got.MatchedAlbumID != album.ID {
t.Errorf("status %v matched %v, want completed on %v", got.Status, got.MatchedAlbumID, album.ID)
}
}
// A re-acquisition request names an album whose row never went away. The row
// alone must not complete it: nothing has come back until a track is on disk.
func TestReconciler_AlbumWithEveryTrackMissingStaysApproved(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
enableLidarrForPool(t, pool)
user := seedUser(t, pool)
const artistMBID, release = "rg-artist-missing", "rg-release-missing"
artist := seedArtist(t, q, "Missing Artist", artistMBID)
album := seedAlbum(t, q, artist.ID, "Missing Album", release)
tr := seedTrack(t, q, album.ID, artist.ID, "Gone", "/music/rg-missing/01.flac")
if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", tr.ID); err != nil {
t.Fatal(err)
}
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Missing Artist",
LidarrAlbumMBID: release, AlbumTitle: "Missing Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool), nil, newTestLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != dbq.LidarrRequestStatusApproved {
t.Errorf("status = %v, want approved while every track is missing", got.Status)
}
}
// fakeAlbumLidarr answers album lookups only for knownGroup (Lidarr's metadata
// is keyed by release group) and records every add.
type fakeAlbumLidarr struct {
knownGroup string
mu sync.Mutex
added []string
}
func (f *fakeAlbumLidarr) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/metadataprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
case strings.HasSuffix(r.URL.Path, "/album/lookup"):
if r.URL.Query().Get("term") == "lidarr:"+f.knownGroup {
_, _ = w.Write([]byte(`[{"foreignAlbumId":"` + f.knownGroup + `","artist":{"id":42}}]`))
return
}
_, _ = w.Write([]byte(`[]`))
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/album"):
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
f.mu.Lock()
f.added = append(f.added, body["foreignAlbumId"].(string))
f.mu.Unlock()
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":9}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}
// The ~49 requests on the deploy were stored under release ids. The reconciler
// resolves the group (MusicBrainz here, since no album row knows it), rewrites
// the request, adds it, and caches the group onto the album holding the
// release.
func TestReconciler_RepointsAReleaseIdRequestAtItsReleaseGroup(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
user := seedUser(t, pool)
const artistMBID, release, group = "rg-artist-repoint", "rg-release-repoint", "rg-group-repoint"
fake := &fakeAlbumLidarr{knownGroup: group}
srv := httptest.NewServer(http.HandlerFunc(fake.handler))
t.Cleanup(srv.Close)
if err := lidarrconfig.New(pool).Save(ctx, lidarrconfig.Config{
Enabled: true, BaseURL: srv.URL, APIKey: "k",
DefaultQualityProfileID: 7, DefaultRootFolderPath: "/music",
}); err != nil {
t.Fatal(err)
}
artist := seedArtist(t, q, "Repoint Artist", artistMBID)
album := seedAlbum(t, q, artist.ID, "Repoint Album", release)
tr := seedTrack(t, q, album.ID, artist.ID, "Lost", "/music/rg-repoint/01.flac")
if _, err := pool.Exec(ctx, "UPDATE tracks SET missing_since = now() WHERE id = $1", tr.ID); err != nil {
t.Fatal(err)
}
req := seedApprovedRequestDirect(t, q, user, CreateParams{
Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Repoint Artist",
LidarrAlbumMBID: release, AlbumTitle: "Repoint Album",
})
rec := NewReconciler(pool, lidarrconfig.New(pool),
func() *lidarr.Client { return lidarr.NewClient(srv.URL, "k") }, newTestLogger(), nil)
asked := 0
rec.releaseGroup = func(_ context.Context, id string) (string, error) {
asked++
if id == release {
return group, nil
}
return "", tags.ErrNotFound
}
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("tickOnce: %v", err)
}
got, err := q.GetLidarrRequestByID(ctx, req.ID)
if err != nil {
t.Fatal(err)
}
if got.LidarrAlbumMbid == nil || *got.LidarrAlbumMbid != group {
t.Errorf("request album mbid = %v, want the release group %s", got.LidarrAlbumMbid, group)
}
if !got.LidarrAddConfirmedAt.Valid {
t.Error("add not confirmed after the repoint")
}
fake.mu.Lock()
added := append([]string(nil), fake.added...)
fake.mu.Unlock()
if len(added) != 1 || added[0] != group {
t.Errorf("Lidarr adds = %v, want [%s]", added, group)
}
var cached *string
if err := pool.QueryRow(ctx, "SELECT release_group_mbid FROM albums WHERE id = $1", album.ID).Scan(&cached); err != nil {
t.Fatal(err)
}
if cached == nil || *cached != group {
t.Errorf("album release group = %v, want %s cached", cached, group)
}
if got.Status != dbq.LidarrRequestStatusApproved {
t.Errorf("status = %v, want approved until a track is back", got.Status)
}
// The next tick finds the group on the request and the album: no second
// MusicBrainz question.
before := asked
if err := rec.tickOnce(ctx); err != nil {
t.Fatalf("second tick: %v", err)
}
if asked != before {
t.Errorf("MusicBrainz asked again on the next tick (%d -> %d)", before, asked)
}
}