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)
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

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>
This commit is contained in:
2026-10-10 22:12:30 -04:00
co-authored by Claude Opus 5.5
parent 10be068dcc
commit a8786abace
23 changed files with 1224 additions and 135 deletions
+89 -1
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/go-chi/chi/v5"
@@ -19,16 +20,25 @@ import (
)
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(http.MethodGet, path, nil), user))
r.ServeHTTP(w, withUser(httptest.NewRequest(method, path, strings.NewReader(body)), user))
return w
}
@@ -281,3 +291,81 @@ func TestWantTracks_HeldAlbumShowsTheLibrarysTracks(t *testing.T) {
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)
}
}