feat(lidarr): ask Lidarr for release groups; repair stored release-id requests (M483 #5244)
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
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
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>
This commit is contained in:
@@ -2,6 +2,7 @@ package lidarrrequests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||
)
|
||||
|
||||
// Reconciler is a background worker that periodically scans approved
|
||||
@@ -30,6 +32,10 @@ type Reconciler struct {
|
||||
bus *eventbus.Bus
|
||||
tick time.Duration
|
||||
batch int32
|
||||
// releaseGroup names the MusicBrainz release group of a release id, to
|
||||
// repair a request stored under a release id Lidarr does not index
|
||||
// (#5241). A field so tests can stand in for MusicBrainz.
|
||||
releaseGroup func(ctx context.Context, releaseMBID string) (string, error)
|
||||
}
|
||||
|
||||
// NewReconciler constructs a Reconciler with production defaults:
|
||||
@@ -50,6 +56,8 @@ func NewReconciler(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func(
|
||||
bus: bus,
|
||||
tick: 5 * time.Minute,
|
||||
batch: 50,
|
||||
|
||||
releaseGroup: tags.ReleaseGroupForRelease,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,12 +193,84 @@ func (r *Reconciler) ensureLidarrAdd(ctx context.Context, q *dbq.Queries, cfg li
|
||||
// cfg.Enabled, so this is just defensive — nothing to do.
|
||||
return nil
|
||||
}
|
||||
if err := sendLidarrAdd(ctx, client, cfg, row); err != nil {
|
||||
err := sendLidarrAdd(ctx, client, cfg, row)
|
||||
if errors.Is(err, lidarr.ErrNotFound) {
|
||||
// Lidarr's metadata has no album under this id. Requests made before
|
||||
// albums carried a release group (#5241) name the album by its
|
||||
// MusicBrainz release id, which Lidarr does not index: repoint the
|
||||
// request at the release group and try once more.
|
||||
if repaired, ok := r.repointAtReleaseGroup(ctx, q, row); ok {
|
||||
row = repaired
|
||||
err = sendLidarrAdd(ctx, client, cfg, row)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("lidarr add retry: %w", err)
|
||||
}
|
||||
return q.MarkLidarrRequestAddConfirmed(ctx, row.ID)
|
||||
}
|
||||
|
||||
// repointAtReleaseGroup reads an album or track request's album id as a
|
||||
// MusicBrainz release and, when its release group can be named, rewrites the
|
||||
// request to it. The library's own albums answer first; MusicBrainz is asked
|
||||
// only for a release no album row knows the group of, and its answer is cached
|
||||
// onto that album. Reports false when there is nothing to repoint: not an
|
||||
// album request, the group is unknown, or the id already is the group.
|
||||
func (r *Reconciler) repointAtReleaseGroup(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) (dbq.LidarrRequest, bool) {
|
||||
if row.Kind == dbq.LidarrRequestKindArtist || row.LidarrAlbumMbid == nil || *row.LidarrAlbumMbid == "" {
|
||||
return row, false
|
||||
}
|
||||
release := *row.LidarrAlbumMbid
|
||||
group, err := q.GetReleaseGroupForReleaseMbid(ctx, &release)
|
||||
if err != nil {
|
||||
if !isNoRows(err) {
|
||||
r.logger.Warn("lidarrrequests: release group lookup failed", "request_id", row.ID, "err", err)
|
||||
return row, false
|
||||
}
|
||||
group, err = r.releaseGroup(ctx, release)
|
||||
if err != nil {
|
||||
if !errors.Is(err, tags.ErrNotFound) {
|
||||
r.logger.Warn("lidarrrequests: MusicBrainz release group lookup failed",
|
||||
"request_id", row.ID, "release_mbid", release, "err", err)
|
||||
}
|
||||
return row, false
|
||||
}
|
||||
if cerr := q.SetReleaseGroupForReleaseMbidIfNull(ctx, dbq.SetReleaseGroupForReleaseMbidIfNullParams{
|
||||
ReleaseGroupMbid: group,
|
||||
ReleaseMbid: release,
|
||||
}); cerr != nil {
|
||||
r.logger.Warn("lidarrrequests: cache release group failed", "release_mbid", release, "err", cerr)
|
||||
}
|
||||
}
|
||||
if group == "" || group == release {
|
||||
return row, false
|
||||
}
|
||||
if err := q.SetLidarrRequestAlbumMbid(ctx, dbq.SetLidarrRequestAlbumMbidParams{
|
||||
ID: row.ID,
|
||||
LidarrAlbumMbid: &group,
|
||||
}); err != nil {
|
||||
r.logger.Warn("lidarrrequests: repoint request failed", "request_id", row.ID, "err", err)
|
||||
return row, false
|
||||
}
|
||||
r.logger.Info("lidarrrequests: request repointed from release to release group",
|
||||
"request_id", row.ID, "release_mbid", release, "release_group_mbid", group)
|
||||
row.LidarrAlbumMbid = &group
|
||||
return row, true
|
||||
}
|
||||
|
||||
// albumForRequest finds the library album a request's album id names, by
|
||||
// release id or release group, and only once it has a track back on disk. A
|
||||
// re-acquisition request names an album whose row never went away; matching
|
||||
// the row alone would complete the request before Lidarr delivered anything.
|
||||
// An exact release match is preferred over another release of the group.
|
||||
const albumForRequest = `
|
||||
SELECT a.id
|
||||
FROM albums a
|
||||
WHERE (a.mbid = $1 OR a.release_group_mbid = $1)
|
||||
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL)
|
||||
ORDER BY (a.mbid = $1) DESC, a.id
|
||||
LIMIT 1`
|
||||
|
||||
func (r *Reconciler) reconcileArtist(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error {
|
||||
var artistID pgtype.UUID
|
||||
err := r.pool.QueryRow(ctx,
|
||||
@@ -221,10 +301,7 @@ func (r *Reconciler) reconcileAlbum(ctx context.Context, q *dbq.Queries, row dbq
|
||||
return nil
|
||||
}
|
||||
var albumID pgtype.UUID
|
||||
err := r.pool.QueryRow(ctx,
|
||||
"SELECT id FROM albums WHERE mbid = $1",
|
||||
*row.LidarrAlbumMbid,
|
||||
).Scan(&albumID)
|
||||
err := r.pool.QueryRow(ctx, albumForRequest, *row.LidarrAlbumMbid).Scan(&albumID)
|
||||
if err != nil {
|
||||
if isNoRows(err) {
|
||||
return nil
|
||||
@@ -250,10 +327,7 @@ func (r *Reconciler) reconcileTrack(ctx context.Context, q *dbq.Queries, row dbq
|
||||
}
|
||||
// Track-kind requests match via their parent album's MBID, not track.mbid.
|
||||
var albumID pgtype.UUID
|
||||
err := r.pool.QueryRow(ctx,
|
||||
"SELECT id FROM albums WHERE mbid = $1",
|
||||
*row.LidarrAlbumMbid,
|
||||
).Scan(&albumID)
|
||||
err := r.pool.QueryRow(ctx, albumForRequest, *row.LidarrAlbumMbid).Scan(&albumID)
|
||||
if err != nil {
|
||||
if isNoRows(err) {
|
||||
return nil
|
||||
@@ -264,7 +338,7 @@ func (r *Reconciler) reconcileTrack(ctx context.Context, q *dbq.Queries, row dbq
|
||||
// Load any track from that album to set matched_track_id.
|
||||
var trackID pgtype.UUID
|
||||
err = r.pool.QueryRow(ctx,
|
||||
"SELECT id FROM tracks WHERE album_id = $1 ORDER BY id LIMIT 1",
|
||||
"SELECT id FROM tracks WHERE album_id = $1 AND missing_since IS NULL ORDER BY id LIMIT 1",
|
||||
albumID,
|
||||
).Scan(&trackID)
|
||||
if err != nil {
|
||||
|
||||
@@ -152,6 +152,7 @@ func TestReconciler_MatchesAlbumByMBID(t *testing.T) {
|
||||
|
||||
artist := seedArtist(t, q, "Test Artist", artistMBID)
|
||||
album := seedAlbum(t, q, artist.ID, "Test Album", albumMBID)
|
||||
_ = seedTrack(t, q, album.ID, artist.ID, "Album Track", "/music/album-test/01.flac")
|
||||
|
||||
req := seedApprovedRequestDirect(t, q, user, CreateParams{
|
||||
Kind: "album", LidarrArtistMBID: artistMBID, ArtistName: "Test Artist",
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user