feat: a wanted list — follow artists on release-type profiles; requests and re-acquisition become wants (M502 #5520)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
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
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
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
Minstrel keeps its own list of what it is to get, keyed by MusicBrainz release group. A want comes from an approved request, a new release by a followed artist, or an album whose files went missing, and is held once the library has a full edition of it. - Migration 0081: release_type_profiles (Standard seeded: Album, EP, Single, studio only), follows, follow_seen_groups and wants. lidarr_requests becomes requests with plain *_mbid columns; approved album and track requests move onto the wanted list, approved artist requests become follows, and the library's album artists start out followed. missing_reacquisitions and the retry settings go. - internal/wanted: follows are checked against MusicBrainz discographies daily. The first check sets a baseline, and only releases after it are wanted unless the follow asked for what already exists. Profiles filter by primary and secondary type, so live recordings stay out unless a profile ticks Live. - internal/requests (was lidarrrequests): approving puts the request on the wanted list, and an artist request can be approved on a chosen profile. A request completes when its want is held. - The re-acquisition sweeper wants a missing album back instead of asking Lidarr; searching for it is the search schedule's (#5521). - Web: an admin Wanted page (Wanted, Missing files, Held, Following), a follow control on artist pages, a release-type profiles card on Integrations, "Approve on a profile" on Requests, and want state on Missing files. The artist detail API returns the artist's MBID. - Android: the request body sends artist_mbid, album_mbid and track_mbid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,16 +6,13 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"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/lidarrrequests"
|
||||
)
|
||||
|
||||
// newAdminRequestsRouter builds a test chi router with the three admin/requests
|
||||
@@ -52,21 +49,10 @@ func doAdminRequestReq(t *testing.T, h *handlers, method, path string, body []by
|
||||
return w
|
||||
}
|
||||
|
||||
// testHandlersWithClientFn creates a handlers instance with a real clientFn
|
||||
// so Service.Approve actually calls Lidarr during tests.
|
||||
func testHandlersWithClientFn(t *testing.T) (*handlers, *lidarrconfig.Service) {
|
||||
// testHandlersWithClientFn is the request handlers' test fixture.
|
||||
func testHandlersWithClientFn(t *testing.T) (*handlers, *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
h, _ := testHandlers(t)
|
||||
lidarrCfg := lidarrconfig.New(h.pool)
|
||||
clientFn := func() *lidarr.Client {
|
||||
cfg, err := lidarrCfg.Get(context.Background())
|
||||
if err != nil || !cfg.Enabled || cfg.BaseURL == "" {
|
||||
return nil
|
||||
}
|
||||
return lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
|
||||
}
|
||||
h.lidarrRequests = lidarrrequests.NewService(h.pool, lidarrCfg, clientFn, nil)
|
||||
return h, lidarrCfg
|
||||
return testHandlers(t)
|
||||
}
|
||||
|
||||
// seedPendingArtistRequest creates a pending artist-kind lidarr request row
|
||||
@@ -90,7 +76,7 @@ func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) {
|
||||
|
||||
// Reject one request directly via service.
|
||||
rv3 := createArtistRequest(t, h, alice, "mbid-reject-1", "Reject Me")
|
||||
_, err := h.lidarrRequests.Reject(context.Background(), rv3.ID, admin.ID, "test")
|
||||
_, err := h.requests.Reject(context.Background(), rv3.ID, admin.ID, "test")
|
||||
if err != nil {
|
||||
t.Fatalf("reject seed: %v", err)
|
||||
}
|
||||
@@ -126,7 +112,7 @@ func TestHandleListAdminRequests_StatusFilter(t *testing.T) {
|
||||
createArtistRequest(t, h, alice, "mbid-pending-f1", "Pending Filter 1")
|
||||
|
||||
rv2 := createArtistRequest(t, h, alice, "mbid-rejected-f1", "Rejected Filter 1")
|
||||
_, err := h.lidarrRequests.Reject(context.Background(), rv2.ID, admin.ID, "")
|
||||
_, err := h.requests.Reject(context.Background(), rv2.ID, admin.ID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("reject seed: %v", err)
|
||||
}
|
||||
@@ -169,198 +155,19 @@ func TestHandleListAdminRequests_BadStatus_400(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleApproveRequest_HappyPath seeds a Lidarr stub, seeds a pending
|
||||
// artist request, and verifies POST /approve → 200 with row status=approved.
|
||||
func TestHandleApproveRequest_HappyPath(t *testing.T) {
|
||||
h, _ := testHandlersWithClientFn(t)
|
||||
resetLidarrState(t, h)
|
||||
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/metadataprofile"):
|
||||
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
|
||||
case strings.Contains(r.URL.Path, "/qualityprofile"):
|
||||
_, _ = w.Write([]byte(`[{"id":1,"name":"Lossless"}]`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"id":1}`))
|
||||
}
|
||||
}))
|
||||
t.Cleanup(stub.Close)
|
||||
|
||||
saveLidarrConfig(t, h, stub.URL, true)
|
||||
|
||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||
|
||||
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-approve", "Approve Me")
|
||||
|
||||
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var got requestView
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
||||
}
|
||||
if got.Status != "approved" {
|
||||
t.Errorf("status = %q, want approved", got.Status)
|
||||
}
|
||||
if got.ID != rv.ID {
|
||||
t.Errorf("id mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleApproveRequest_OverrideUsed seeds config with default qp=1/root="/m",
|
||||
// approves with override qp=5/root="/other", verifies the stub received those
|
||||
// values and the row reflects the overrides.
|
||||
func TestHandleApproveRequest_OverrideUsed(t *testing.T) {
|
||||
h, _ := testHandlersWithClientFn(t)
|
||||
resetLidarrState(t, h)
|
||||
|
||||
var capturedBody []byte
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := r.Body.Read(buf)
|
||||
capturedBody = buf[:n]
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/metadataprofile"):
|
||||
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
|
||||
case strings.Contains(r.URL.Path, "/qualityprofile"):
|
||||
_, _ = w.Write([]byte(`[{"id":1,"name":"Lossless"}]`))
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"id":1}`))
|
||||
}
|
||||
}))
|
||||
t.Cleanup(stub.Close)
|
||||
|
||||
// Save config with default qp=1, root="/m".
|
||||
if err := lidarrconfig.New(h.pool).Save(context.Background(), lidarrconfig.Config{
|
||||
Enabled: true,
|
||||
BaseURL: stub.URL,
|
||||
APIKey: "test-key",
|
||||
DefaultQualityProfileID: 1,
|
||||
DefaultRootFolderPath: "/m",
|
||||
}); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||
|
||||
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-override", "Override Me")
|
||||
|
||||
body := []byte(`{"quality_profile_id":5,"root_folder_path":"/other"}`)
|
||||
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", body, admin)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify stub received the override values.
|
||||
var stubPayload map[string]any
|
||||
if err := json.Unmarshal(capturedBody, &stubPayload); err != nil {
|
||||
t.Fatalf("decode stub payload: %v; body = %s", err, capturedBody)
|
||||
}
|
||||
if qp, _ := stubPayload["qualityProfileId"].(float64); int(qp) != 5 {
|
||||
t.Errorf("stub qualityProfileId = %v, want 5", stubPayload["qualityProfileId"])
|
||||
}
|
||||
if rf, _ := stubPayload["rootFolderPath"].(string); rf != "/other" {
|
||||
t.Errorf("stub rootFolderPath = %q, want /other", rf)
|
||||
}
|
||||
|
||||
// Verify the returned row reflects the overrides.
|
||||
var got requestView
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode response: %v; body = %s", err, w.Body.String())
|
||||
}
|
||||
if got.QualityProfileID == nil || *got.QualityProfileID != 5 {
|
||||
t.Errorf("quality_profile_id = %v, want 5", got.QualityProfileID)
|
||||
}
|
||||
if got.RootFolderPath == nil || *got.RootFolderPath != "/other" {
|
||||
t.Errorf("root_folder_path = %v, want /other", got.RootFolderPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleApproveRequest_LidarrUnreachable_ApprovesDeferred points config
|
||||
// at an unreachable port and verifies the durable-approve contract: POST
|
||||
// /approve still succeeds (200) and the request becomes approved with the
|
||||
// Lidarr add deferred (lidarr_add_confirmed_at NULL) for the reconciler to
|
||||
// retry — the admin's decision is never lost to a transient Lidarr outage.
|
||||
func TestHandleApproveRequest_LidarrUnreachable_ApprovesDeferred(t *testing.T) {
|
||||
h, _ := testHandlersWithClientFn(t)
|
||||
resetLidarrState(t, h)
|
||||
|
||||
saveLidarrConfig(t, h, "http://127.0.0.1:1", true)
|
||||
|
||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||
|
||||
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-unreachable", "Unreachable")
|
||||
|
||||
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (durable approve despite Lidarr down); body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Must no longer be pending.
|
||||
pending, err := h.lidarrRequests.ListByStatus(context.Background(), "pending", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list pending: %v", err)
|
||||
}
|
||||
for _, r := range pending {
|
||||
if r.ID == rv.ID {
|
||||
t.Error("row still pending after durable approve")
|
||||
}
|
||||
}
|
||||
|
||||
// Must be approved with the add deferred (confirmed_at NULL) so the
|
||||
// reconciler retries it — not failed, not lost.
|
||||
approved, err := h.lidarrRequests.ListByStatus(context.Background(), "approved", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list approved: %v", err)
|
||||
}
|
||||
var got *dbq.LidarrRequest
|
||||
for i := range approved {
|
||||
if approved[i].ID == rv.ID {
|
||||
got = &approved[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("row %s not in approved after approve with Lidarr down", uuidToString(rv.ID))
|
||||
}
|
||||
if got.LidarrAddConfirmedAt.Valid {
|
||||
t.Error("lidarr_add_confirmed_at set despite Lidarr unreachable; want NULL (deferred to reconciler)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleApproveRequest_NotPending_409 pre-rejects a row and verifies
|
||||
// POST /approve → 409 request_not_pending.
|
||||
func TestHandleApproveRequest_NotPending_409(t *testing.T) {
|
||||
h, _ := testHandlersWithClientFn(t)
|
||||
resetLidarrState(t, h)
|
||||
|
||||
// Need an enabled Lidarr config so Approve reaches the not-pending check.
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"id":1}`))
|
||||
}))
|
||||
t.Cleanup(stub.Close)
|
||||
saveLidarrConfig(t, h, stub.URL, true)
|
||||
|
||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||
|
||||
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-notpending", "Not Pending")
|
||||
|
||||
// Pre-reject the row.
|
||||
_, err := h.lidarrRequests.Reject(context.Background(), rv.ID, admin.ID, "pre-rejected")
|
||||
_, err := h.requests.Reject(context.Background(), rv.ID, admin.ID, "pre-rejected")
|
||||
if err != nil {
|
||||
t.Fatalf("pre-reject: %v", err)
|
||||
}
|
||||
@@ -470,3 +277,62 @@ func TestAdminRequestsEndpoints_NonAdmin403(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleApproveRequest_AlbumBecomesAWant: approving an album request puts
|
||||
// its release group on the wanted list, as the requester's.
|
||||
func TestHandleApproveRequest_AlbumBecomesAWant(t *testing.T) {
|
||||
h, _ := testHandlersWithClientFn(t)
|
||||
resetLidarrState(t, h)
|
||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||
|
||||
w := doCreateRequest(h, alice, `{"kind":"album","artist_mbid":"artist-x","artist_name":"Gorillaz","album_mbid":"group-x","album_title":"Demon Days"}`)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var rv requestView
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &rv)
|
||||
|
||||
resp := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("approve: %d %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
want, err := dbq.New(h.pool).GetWantByReleaseGroup(context.Background(), "group-x")
|
||||
if err != nil {
|
||||
t.Fatalf("no want recorded: %v", err)
|
||||
}
|
||||
if want.Title != "Demon Days" || want.CreatedBy != alice.ID || want.Reason != "request" {
|
||||
t.Errorf("want = %+v", want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleApproveRequest_ArtistOnTheChosenProfile: an artist request is
|
||||
// followed on the profile the admin picked; an unknown profile is a 400.
|
||||
func TestHandleApproveRequest_ArtistOnTheChosenProfile(t *testing.T) {
|
||||
h, _ := testHandlersWithClientFn(t)
|
||||
resetLidarrState(t, h)
|
||||
alice := seedUser(t, h.pool, "alice", "pw", false)
|
||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||
ctx := context.Background()
|
||||
|
||||
profile, err := dbq.New(h.pool).CreateReleaseTypeProfile(ctx, dbq.CreateReleaseTypeProfileParams{
|
||||
Name: "Albums only", PrimaryTypes: []string{"Album"}, SecondaryTypes: []string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rv := seedPendingArtistRequest(t, h, alice, "artist-profiled", "Profiled")
|
||||
path := "/api/admin/requests/" + uuidToString(rv.ID) + "/approve"
|
||||
|
||||
if resp := doAdminRequestReq(t, h, http.MethodPost, path, []byte(`{"profile_id":"00000000-0000-4000-8000-000000000000"}`), admin); resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unknown profile: %d %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
body := []byte(`{"profile_id":"` + uuidToString(profile.ID) + `"}`)
|
||||
if resp := doAdminRequestReq(t, h, http.MethodPost, path, body, admin); resp.Code != http.StatusOK {
|
||||
t.Fatalf("approve: %d %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
f, err := dbq.New(h.pool).GetFollow(ctx, "artist-profiled")
|
||||
if err != nil || f.ProfileID != profile.ID || !f.WantExisting {
|
||||
t.Errorf("follow = %+v, %v: on Albums only, wanting what exists", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user