Files
minstrel/internal/api/admin_wanted_test.go
T
bvandeusenandClaude Opus 5.5 a8786abace
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m17s
release / go (push) Successful in 1m34s
release / integration (push) Failing after 5m34s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m44s
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
feat: Collection › Unmatched lists the library albums no MusicBrainz album matched, each matched by hand from a search or a link, or left off (M502 #5579)
Like Lidarr's Unmapped Files. A local band's album, which MusicBrainz will
never have, is left off: never matched again and not counted. A re-tagged
album is matched again on the next listing. The Collection's verdict now
says when library albums need matching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:12:30 -04:00

372 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
func doWantedReq(t *testing.T, h *handlers, path string, user dbq.User) *httptest.ResponseRecorder {
t.Helper()
return doWanted(t, h, http.MethodGet, path, "", user)
}
func doWanted(t *testing.T, h *handlers, method, path, body string, user dbq.User) *httptest.ResponseRecorder {
t.Helper()
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/wanted", h.handleListWants)
admin.Get("/wanted/artists", h.handleListWantArtists)
admin.Get("/wanted/unmatched", h.handleListUnmatched)
admin.Post("/wanted/unmatched/{id}/match", h.handleMatchUnmatched)
admin.Post("/wanted/unmatched/{id}/leave-off", h.handleLeaveUnmatchedOff)
admin.Post("/wanted/unmatched/{id}/put-back", h.handlePutUnmatchedBack)
admin.Get("/wanted/{id}/tracks", h.handleWantTracks)
})
w := httptest.NewRecorder()
r.ServeHTTP(w, withUser(httptest.NewRequest(method, path, strings.NewReader(body)), user))
return w
}
func seedWant(t *testing.T, h *handlers, group, artistMBID, artist, title, date string) dbq.Want {
t.Helper()
w, err := wanted.WantWith(context.Background(), dbq.New(h.pool), wanted.WantParams{
GroupMBID: group, ArtistMBID: artistMBID, ArtistName: artist, Title: title,
PrimaryType: "Album", FirstReleaseDate: date, Reason: wanted.ReasonFollow,
})
if err != nil {
t.Fatal(err)
}
return w
}
func listWanted(t *testing.T, h *handlers, user dbq.User, q string) wantsPage {
t.Helper()
w := doWantedReq(t, h, "/api/admin/wanted?"+url.Values{"q": {q}}.Encode(), user)
if w.Code != http.StatusOK {
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
}
var page wantsPage
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
return page
}
// The list runs like the library (#5572): artists A–Z by the library's sort
// name where the library holds the artist, each artist's albums oldest
// first; q narrows it to an artist or an album.
func TestListWants_ArtistsAToZThenTheirAlbumsByDate(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
beatles := "a0000000-0000-4000-8000-000000000002"
if _, err := dbq.New(h.pool).UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "The Beatles", SortName: "Beatles, The", Mbid: &beatles}); err != nil {
t.Fatal(err)
}
seedWant(t, h, "g0000000-0000-4000-8000-000000000001", "a0000000-0000-4000-8000-000000000003", "Zeds", "Zed Album", "2001")
seedWant(t, h, "g0000000-0000-4000-8000-000000000002", beatles, "The Beatles", "Abbey Road", "1969-09-26")
seedWant(t, h, "g0000000-0000-4000-8000-000000000003", beatles, "The Beatles", "Please Please Me", "1963-03-22")
seedWant(t, h, "g0000000-0000-4000-8000-000000000004", "a0000000-0000-4000-8000-000000000001", "Camel", "Moonmadness", "1976")
page := listWanted(t, h, admin, "")
var got []string
for _, it := range page.Items {
got = append(got, it.ArtistName+"/"+it.Title)
}
want := "[The Beatles/Please Please Me The Beatles/Abbey Road Camel/Moonmadness Zeds/Zed Album]"
if fmt.Sprint(got) != want {
t.Errorf("order = %v, want %s", got, want)
}
if page.Items[0].ArtistID == "" || page.Items[2].ArtistID != "" {
t.Errorf("artist ids = %q, %q: only an artist the library holds has one", page.Items[0].ArtistID, page.Items[2].ArtistID)
}
page = listWanted(t, h, admin, "BEATLES")
if len(page.Items) != 2 || page.Total != 2 {
t.Errorf("q=BEATLES: %d items, total %d; want the two Beatles albums", len(page.Items), page.Total)
}
page = listWanted(t, h, admin, "moon")
if len(page.Items) != 1 || page.Items[0].Title != "Moonmadness" || page.Total != 1 {
t.Errorf("q=moon: %+v, total %d", page.Items, page.Total)
}
if page = listWanted(t, h, admin, "nothing like it"); len(page.Items) != 0 || page.Total != 0 {
t.Errorf("no match: %d items, total %d", len(page.Items), page.Total)
}
}
// The Collection's catalogue (#5572): every artist on the list A–Z, each
// with how many of their albums are held; an artist's page lists every album
// of theirs, held or not, under that artist.
func TestListWantArtists_CatalogueAndAnArtistsPage(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
q := dbq.New(h.pool)
beatles := "a0000000-0000-4000-8000-000000000002"
camel := "a0000000-0000-4000-8000-000000000001"
if _, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "The Beatles", SortName: "Beatles, The", Mbid: &beatles}); err != nil {
t.Fatal(err)
}
seedWant(t, h, "g0000000-0000-4000-8000-000000000002", beatles, "The Beatles", "Abbey Road", "1969")
held := seedWant(t, h, "g0000000-0000-4000-8000-000000000003", beatles, "The Beatles", "Please Please Me", "1963")
if _, err := h.pool.Exec(ctx, `UPDATE wants SET fulfilled_at = now() WHERE id = $1`, held.ID); err != nil {
t.Fatal(err)
}
seedWant(t, h, "g0000000-0000-4000-8000-000000000004", camel, "Camel", "Moonmadness", "1976")
if _, err := h.pool.Exec(ctx, `INSERT INTO follows (artist_mbid, artist_name, profile_id)
SELECT $1, 'Camel', id FROM release_type_profiles WHERE is_default`, camel); err != nil {
t.Fatal(err)
}
w := doWantedReq(t, h, "/api/admin/wanted/artists", admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
}
var page wantArtistsPage
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
if len(page.Items) != 2 || page.Total != 2 || page.Counts.Artists != 2 {
t.Fatalf("catalogue = %+v, total %d, artists %d", page.Items, page.Total, page.Counts.Artists)
}
b, c := page.Items[0], page.Items[1]
if b.ArtistName != "The Beatles" || b.SortName != "Beatles, The" || b.Albums != 2 || b.Held != 1 || b.Wanted != 1 ||
b.ArtistID == "" || b.Following || b.ImageURL == "" {
t.Errorf("Beatles = %+v", b)
}
if c.ArtistName != "Camel" || c.Albums != 1 || c.Held != 0 || !c.Following || c.ArtistID != "" {
t.Errorf("Camel = %+v", c)
}
// q finds an artist by an album's title.
w = doWantedReq(t, h, "/api/admin/wanted/artists?q=moon", admin)
page = wantArtistsPage{}
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.Items[0].ArtistName != "Camel" {
t.Errorf("q=moon: %+v", page.Items)
}
// The Beatles' page: both albums, held or not, headed by the artist.
w = doWantedReq(t, h, "/api/admin/wanted?state=all&artist="+beatles, admin)
var albums wantsPage
if err := json.Unmarshal(w.Body.Bytes(), &albums); err != nil {
t.Fatal(err)
}
if len(albums.Items) != 2 || albums.Items[0].Title != "Please Please Me" || albums.Items[0].HeldAt == nil ||
albums.Artist == nil || albums.Artist.Held != 1 {
t.Errorf("artist page = %+v, head %+v", albums.Items, albums.Artist)
}
if w := doWantedReq(t, h, "/api/admin/wanted?state=all&artist=nobody", admin); w.Code != http.StatusNotFound {
t.Errorf("an artist with nothing on the list: status %d, want 404", w.Code)
}
}
// fakeEditions is a MusicBrainz holding one group with two editions, the
// shorter official one the standard, or 503 when down is set.
func fakeEditions(t *testing.T, group string, down bool) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if down {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
switch {
case r.URL.Path == "/release-group/"+group:
_, _ = fmt.Fprintf(w, `{"id": %q, "title": "Moonmadness", "primary-type": "Album", "first-release-date": "1976"}`, group)
case r.URL.Path == "/release" && r.URL.Query().Get("release-group") == group:
_, _ = fmt.Fprint(w, `{"release-count": 2, "releases": [
{"id": "r-deluxe", "title": "Moonmadness", "status": "Official", "date": "2009", "country": "GB",
"media": [{"position": 1, "format": "CD", "tracks": [
{"position": 1, "number": "1", "title": "Aristillus", "length": 120000, "recording": {"id": "rec-1"}},
{"position": 2, "number": "2", "title": "Song Within a Song", "length": 433000, "recording": {"id": "rec-2"}},
{"position": 3, "number": "3", "title": "Bonus", "length": 200000, "recording": {"id": "rec-3"}}]}]},
{"id": "r-orig", "title": "Moonmadness", "status": "Official", "date": "1976-03", "country": "GB",
"media": [{"position": 1, "format": "12\" Vinyl", "tracks": [
{"position": 1, "number": "A1", "title": "Aristillus", "length": 120000, "recording": {"id": "rec-1"}},
{"position": 2, "number": "A2", "title": "Song Within a Song", "length": 433000, "recording": {"id": "rec-2"}}]}]}]}`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return srv
}
func wantTracks(t *testing.T, h *handlers, user dbq.User, id string) (int, wantTracksView) {
t.Helper()
w := doWantedReq(t, h, "/api/admin/wanted/"+id+"/tracks", user)
var out wantTracksView
if w.Code == http.StatusOK {
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
}
return w.Code, out
}
// An album not yet held shows MusicBrainz's standard edition: the shortest
// official one.
func TestWantTracks_StandardEditionFromMusicBrainz(t *testing.T) {
const group = "g0000000-0000-4000-8000-000000000010"
for _, down := range []bool{false, true} {
h, pool := testHandlersWithClientFn(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
h.catalogue = catalogue.New(pool, &musicbrainz.Client{BaseURL: fakeEditions(t, group, down).URL,
Pace: &musicbrainz.Pacer{}, Retries: -1}, h.logger)
w := seedWant(t, h, group, "a0000000-0000-4000-8000-000000000001", "Camel", "Moonmadness", "1976")
code, out := wantTracks(t, h, admin, uuidToString(w.ID))
if down {
if code != http.StatusServiceUnavailable {
t.Errorf("MusicBrainz down: status %d, want 503", code)
}
continue
}
if code != http.StatusOK || out.Source != "musicbrainz" || len(out.Tracks) != 2 {
t.Fatalf("status %d, %+v: want the two-track original", code, out)
}
if out.Edition != "1976 · 12\" Vinyl · GB" {
t.Errorf("edition = %q", out.Edition)
}
if tr := out.Tracks[1]; tr.Disc != 1 || tr.Number != 2 || tr.Title != "Song Within a Song" || tr.LengthMs != 433000 {
t.Errorf("track 2 = %+v", tr)
}
}
}
// A held album shows the library's own tracks, a lost file marked.
func TestWantTracks_HeldAlbumShowsTheLibrarysTracks(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
q := dbq.New(h.pool)
mbid := "a0000000-0000-4000-8000-000000000001"
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Camel", SortName: "Camel", Mbid: &mbid})
if err != nil {
t.Fatal(err)
}
album, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Moonmadness", SortTitle: "Moonmadness", ArtistID: artist.ID})
if err != nil {
t.Fatal(err)
}
for i, title := range []string{"Aristillus", "Song Within a Song"} {
num := int32(i + 1)
if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: title, AlbumID: album.ID, ArtistID: artist.ID, TrackNumber: &num, DurationMs: 120000,
FilePath: fmt.Sprintf("/music/camel/%02d.flac", i+1), FileSize: 1, FileFormat: "flac",
}); err != nil {
t.Fatal(err)
}
}
if _, err := h.pool.Exec(ctx, `UPDATE tracks SET missing_since = now() WHERE title = 'Aristillus'`); err != nil {
t.Fatal(err)
}
w := seedWant(t, h, "g0000000-0000-4000-8000-000000000011", mbid, "Camel", "Moonmadness", "1976")
if err := q.FulfillWant(ctx, dbq.FulfillWantParams{ID: w.ID, AlbumID: album.ID}); err != nil {
t.Fatal(err)
}
code, out := wantTracks(t, h, admin, uuidToString(w.ID))
if code != http.StatusOK || out.Source != "library" || len(out.Tracks) != 2 {
t.Fatalf("status %d, %+v", code, out)
}
if !out.Tracks[0].Missing || out.Tracks[1].Missing || out.Tracks[1].Number != 2 {
t.Errorf("tracks = %+v: the first is missing, the second on disk", out.Tracks)
}
}
// The Unmatched section (#5579): the albums matching could not place, a
// local band's album left off and put back, and a link that names no
// MusicBrainz album refused before MusicBrainz is asked.
func TestUnmatched_ListLeaveOffPutBack(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
q := dbq.New(h.pool)
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Annadale", SortName: "Annadale"})
if err != nil {
t.Fatal(err)
}
album, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Dial Tone", SortTitle: "Dial Tone", ArtistID: artist.ID})
if err != nil {
t.Fatal(err)
}
if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: "Intro", AlbumID: album.ID, ArtistID: artist.ID, DurationMs: 120000,
FilePath: "/music/annadale/01.flac", FileSize: 1, FileFormat: "flac",
}); err != nil {
t.Fatal(err)
}
if err := q.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{
AlbumID: album.ID, Outcome: "unmatched", Detail: "MusicBrainz has no album by that name", Matcher: 1}); err != nil {
t.Fatal(err)
}
id := uuidToString(album.ID)
list := func() []unmatchedAlbumView {
t.Helper()
w := doWantedReq(t, h, "/api/admin/wanted/unmatched", admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
}
var out struct {
Items []unmatchedAlbumView `json:"items"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out.Items
}
if got := list(); len(got) != 1 || got[0].AlbumID != id || got[0].Artist != "Annadale" || got[0].Tracks != 1 ||
got[0].LeftOff || got[0].Detail == "" || got[0].DecidedAt == "" {
t.Fatalf("unmatched = %+v", got)
}
for _, c := range []struct {
path, body string
code int
}{
{"/api/admin/wanted/unmatched/" + id + "/match", `{"ref":"Dial Tone"}`, http.StatusBadRequest},
{"/api/admin/wanted/unmatched/" + id + "/match", `nope`, http.StatusBadRequest},
{"/api/admin/wanted/unmatched/not-a-uuid/leave-off", "", http.StatusBadRequest},
{"/api/admin/wanted/unmatched/" + id + "/put-back", "", http.StatusNotFound},
{"/api/admin/wanted/unmatched/" + id + "/leave-off", "", http.StatusNoContent},
{"/api/admin/wanted/unmatched/" + id + "/leave-off", "", http.StatusNotFound},
} {
if w := doWanted(t, h, http.MethodPost, c.path, c.body, admin); w.Code != c.code {
t.Errorf("POST %s %s = %d, want %d; body = %s", c.path, c.body, w.Code, c.code, w.Body.String())
}
}
if got := list(); len(got) != 1 || !got[0].LeftOff {
t.Fatalf("left off = %+v", got)
}
if w := doWanted(t, h, http.MethodPost, "/api/admin/wanted/unmatched/"+id+"/put-back", "", admin); w.Code != http.StatusNoContent {
t.Fatalf("put back = %d; body = %s", w.Code, w.Body.String())
}
if got := list(); len(got) != 1 || got[0].LeftOff {
t.Errorf("put back = %+v", got)
}
user := seedUser(t, h.pool, "listener", "pw", false)
if w := doWantedReq(t, h, "/api/admin/wanted/unmatched", user); w.Code != http.StatusForbidden {
t.Errorf("a listener reads the unmatched section: %d", w.Code)
}
}