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
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:
+123
-31
@@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -72,7 +74,9 @@ type wantGrabView struct {
|
||||
// wantCounts sizes each tab of the Wanted page. Artists: how many artists
|
||||
// the list holds albums of, its main page (#5572).
|
||||
type wantCounts struct {
|
||||
Artists int64 `json:"artists"`
|
||||
Artists int64 `json:"artists"`
|
||||
// Unmatched: library albums waiting to be matched by hand (#5579).
|
||||
Unmatched int64 `json:"unmatched"`
|
||||
Wanted int64 `json:"wanted"`
|
||||
Missing int64 `json:"missing"`
|
||||
Held int64 `json:"held"`
|
||||
@@ -123,33 +127,38 @@ type wantArtistsPage struct {
|
||||
}
|
||||
|
||||
// libraryListingView: whether the library's albums are all on the list
|
||||
// yet, and which could not be matched to MusicBrainz (#5569). Unmatched is
|
||||
// filled on the first page of the held list only.
|
||||
// yet, and how many could not be matched to MusicBrainz (#5569); those are
|
||||
// listed in the Collection's Unmatched section (#5579).
|
||||
type libraryListingView struct {
|
||||
ListedAt *string `json:"listed_at"`
|
||||
Unlisted int32 `json:"unlisted"`
|
||||
Unmatched []unmatchedAlbumView `json:"unmatched"`
|
||||
ListedAt *string `json:"listed_at"`
|
||||
Unlisted int32 `json:"unlisted"`
|
||||
}
|
||||
|
||||
// unmatchedAlbumView is one album in the Collection's Unmatched section
|
||||
// (#5579): why no MusicBrainz album matched it, or that a person left it
|
||||
// off.
|
||||
type unmatchedAlbumView struct {
|
||||
AlbumID string `json:"album_id"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Detail string `json:"detail"`
|
||||
AlbumID string `json:"album_id"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Detail string `json:"detail"`
|
||||
Tracks int32 `json:"tracks"`
|
||||
LeftOff bool `json:"left_off"`
|
||||
DecidedAt string `json:"decided_at"`
|
||||
}
|
||||
|
||||
// unmatchedShown is how many unmatched albums the held list names.
|
||||
const unmatchedShown = 200
|
||||
// unmatchedShown bounds the Unmatched section: a library a listing could
|
||||
// not match more of than this has a bigger problem than one page shows.
|
||||
const unmatchedShown = 500
|
||||
|
||||
const wantsPageSize = 50
|
||||
|
||||
// wantArtistsPageSize fills a few rows of the artist grid a page.
|
||||
const wantArtistsPageSize = 96
|
||||
|
||||
// wantsHeader is what every Wanted page carries: each tab's count, and how
|
||||
// the one-time library listing went. The albums no match was found for are
|
||||
// named only when asked (the main page's first page).
|
||||
func (h *handlers) wantsHeader(r *http.Request, q *dbq.Queries, unmatched bool) (wantCounts, libraryListingView, error) {
|
||||
// wantsHeader is what every Collection page carries: each section's count,
|
||||
// and how the one-time library listing went.
|
||||
func (h *handlers) wantsHeader(r *http.Request, q *dbq.Queries) (wantCounts, libraryListingView, error) {
|
||||
counts, err := q.CountWants(r.Context())
|
||||
if err != nil {
|
||||
return wantCounts{}, libraryListingView{}, err
|
||||
@@ -158,23 +167,12 @@ func (h *handlers) wantsHeader(r *http.Request, q *dbq.Queries, unmatched bool)
|
||||
if err != nil {
|
||||
return wantCounts{}, libraryListingView{}, err
|
||||
}
|
||||
lib := libraryListingView{Unlisted: listing.LibraryUnlisted, Unmatched: []unmatchedAlbumView{}}
|
||||
lib := libraryListingView{Unlisted: listing.LibraryUnlisted}
|
||||
if listing.LibraryListedAt.Valid {
|
||||
at := formatTimestamp(listing.LibraryListedAt)
|
||||
lib.ListedAt = &at
|
||||
}
|
||||
if unmatched && listing.LibraryUnlisted > 0 {
|
||||
rows, err := q.ListUnmatchedLibraryAlbums(r.Context(), unmatchedShown)
|
||||
if err != nil {
|
||||
return wantCounts{}, libraryListingView{}, err
|
||||
}
|
||||
for _, u := range rows {
|
||||
lib.Unmatched = append(lib.Unmatched, unmatchedAlbumView{
|
||||
AlbumID: uuidToString(u.AlbumID), Title: u.Title, Artist: u.ArtistName, Detail: u.Detail,
|
||||
})
|
||||
}
|
||||
}
|
||||
return wantCounts{Artists: counts.Artists, Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held,
|
||||
return wantCounts{Artists: counts.Artists, Unmatched: counts.Unmatched, Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held,
|
||||
Following: counts.Following, Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked}, lib, nil
|
||||
}
|
||||
|
||||
@@ -207,7 +205,7 @@ func (h *handlers) handleListWantArtists(w http.ResponseWriter, r *http.Request)
|
||||
writeErrWithLog(w, h.logger, "admin wanted: artists", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
counts, lib, err := h.wantsHeader(r, q, offset == 0)
|
||||
counts, lib, err := h.wantsHeader(r, q)
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin wanted: counts", apierror.Internal(err))
|
||||
return
|
||||
@@ -244,7 +242,7 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
|
||||
writeErrWithLog(w, h.logger, "admin wanted: list", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
counts, lib, err := h.wantsHeader(r, q, false)
|
||||
counts, lib, err := h.wantsHeader(r, q)
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin wanted: counts", apierror.Internal(err))
|
||||
return
|
||||
@@ -714,3 +712,97 @@ func deref(s *string) string {
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// handleListUnmatched implements GET /api/admin/wanted/unmatched: the
|
||||
// Collection's Unmatched section (#5579), the albums no MusicBrainz album
|
||||
// matched and, after them, the ones a person left off.
|
||||
func (h *handlers) handleListUnmatched(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := dbq.New(h.pool).ListUnmatchedLibraryAlbums(r.Context(), unmatchedShown)
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin wanted: unmatched", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
out := struct {
|
||||
Items []unmatchedAlbumView `json:"items"`
|
||||
}{Items: make([]unmatchedAlbumView, 0, len(rows))}
|
||||
for _, u := range rows {
|
||||
out.Items = append(out.Items, unmatchedAlbumView{
|
||||
AlbumID: uuidToString(u.AlbumID), Title: u.Title, Artist: u.ArtistName, Detail: u.Detail,
|
||||
Tracks: u.Tracks, LeftOff: u.Outcome == "dismissed", DecidedAt: formatTimestamp(u.DecidedAt),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type manualMatchView struct {
|
||||
ReleaseGroupMBID string `json:"release_group_mbid"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Agree int `json:"agree"`
|
||||
Tracks int `json:"tracks"`
|
||||
}
|
||||
|
||||
// handleMatchUnmatched implements POST /api/admin/wanted/unmatched/{id}/match
|
||||
// {"ref": "<MusicBrainz link or id>"}: the album goes on the list as held,
|
||||
// as that MusicBrainz album.
|
||||
func (h *handlers) handleMatchUnmatched(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := requireURLUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, apierror.BadRequest("bad_body", "expected {\"ref\": \"…\"}"))
|
||||
return
|
||||
}
|
||||
m, err := h.wanted.MatchAlbumTo(r.Context(), id, body.Ref)
|
||||
switch {
|
||||
case errors.Is(err, wanted.ErrMatchRef):
|
||||
writeErr(w, apierror.BadRequest("bad_ref", "That isn't a MusicBrainz album link or id."))
|
||||
return
|
||||
case errors.Is(err, wanted.ErrMatchNoGroup):
|
||||
writeErr(w, &apierror.Error{Status: http.StatusUnprocessableEntity, Code: "no_such_album",
|
||||
Message: "MusicBrainz has no album at that link.", Cause: err})
|
||||
return
|
||||
case errors.Is(err, wanted.ErrAlbumNotUnmatched):
|
||||
writeErr(w, apierror.NotFound("unmatched album"))
|
||||
return
|
||||
case errors.Is(err, musicbrainz.ErrUnreachable), errors.Is(err, musicbrainz.ErrBadResponse):
|
||||
writeErr(w, &apierror.Error{Status: http.StatusServiceUnavailable, Code: "musicbrainz_unreachable",
|
||||
Message: "MusicBrainz can't be reached", Cause: err})
|
||||
return
|
||||
case err != nil:
|
||||
writeErrWithLog(w, h.logger, "admin wanted: match by hand", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, manualMatchView{ReleaseGroupMBID: m.Group, Title: m.Title, Artist: m.Artist,
|
||||
Agree: m.Agree, Tracks: m.Tracks})
|
||||
}
|
||||
|
||||
// handleLeaveUnmatchedOff implements POST /api/admin/wanted/unmatched/{id}/leave-off,
|
||||
// and handlePutUnmatchedBack its undoing, POST …/{id}/put-back.
|
||||
func (h *handlers) handleLeaveUnmatchedOff(w http.ResponseWriter, r *http.Request) {
|
||||
h.setUnmatched(w, r, h.wanted.LeaveAlbumOff)
|
||||
}
|
||||
|
||||
func (h *handlers) handlePutUnmatchedBack(w http.ResponseWriter, r *http.Request) {
|
||||
h.setUnmatched(w, r, h.wanted.PutAlbumBack)
|
||||
}
|
||||
|
||||
func (h *handlers) setUnmatched(w http.ResponseWriter, r *http.Request, set func(context.Context, pgtype.UUID) error) {
|
||||
id, ok := requireURLUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
err := set(r.Context(), id)
|
||||
switch {
|
||||
case errors.Is(err, wanted.ErrAlbumNotUnmatched):
|
||||
writeErr(w, apierror.NotFound("unmatched album"))
|
||||
case err != nil:
|
||||
writeErrWithLog(w, h.logger, "admin wanted: unmatched", apierror.Internal(err))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +253,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
// profiles that decide which of their releases count.
|
||||
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.Delete("/wanted/{id}", h.handleRemoveWant)
|
||||
// Searching and grabbing (M502 #5521).
|
||||
admin.Get("/acquisition/settings", h.handleGetSearchSettings)
|
||||
|
||||
Reference in New Issue
Block a user