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
+123 -31
View File
@@ -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)
}
}