diff --git a/internal/api/admin_wanted.go b/internal/api/admin_wanted.go
index d58a20b1..3ff14ad7 100644
--- a/internal/api/admin_wanted.go
+++ b/internal/api/admin_wanted.go
@@ -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": ""}: 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)
+ }
+}
diff --git a/internal/api/admin_wanted_test.go b/internal/api/admin_wanted_test.go
index ecfc70da..b883120d 100644
--- a/internal/api/admin_wanted_test.go
+++ b/internal/api/admin_wanted_test.go
@@ -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)
+ }
+}
diff --git a/internal/api/api.go b/internal/api/api.go
index b8d7d27a..2e958a51 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -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)
diff --git a/internal/db/dbq/wanted.sql.go b/internal/db/dbq/wanted.sql.go
index 22469f12..3d5204b7 100644
--- a/internal/db/dbq/wanted.sql.go
+++ b/internal/db/dbq/wanted.sql.go
@@ -20,14 +20,29 @@ func (q *Queries) ClearDefaultReleaseTypeProfile(ctx context.Context) error {
return err
}
-const countAlbumsToRematch = `-- name: CountAlbumsToRematch :one
-SELECT COUNT(*) FROM library_album_matches
- WHERE outcome = 'unmatched' AND matcher < $1::int
+const countAlbumsToMatch = `-- name: CountAlbumsToMatch :one
+SELECT COUNT(*)
+ FROM albums a
+ WHERE (a.created_at <= $1::timestamptz
+ OR EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.added_at <= $1::timestamptz))
+ AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)
+ AND NOT EXISTS (SELECT 1 FROM library_album_matches m
+ WHERE m.album_id = a.id
+ AND (m.outcome IN ('listed', 'dismissed')
+ OR (m.matcher >= $2::int
+ AND NOT EXISTS (SELECT 1 FROM tracks t
+ WHERE t.album_id = a.id AND t.updated_at > m.decided_at))))
`
-// Refusals by an older matcher, which a finished listing goes back to.
-func (q *Queries) CountAlbumsToRematch(ctx context.Context, matcher int32) (int64, error) {
- row := q.db.QueryRow(ctx, countAlbumsToRematch, matcher)
+type CountAlbumsToMatchParams struct {
+ Cutoff pgtype.Timestamptz
+ Matcher int32
+}
+
+// Whether a finished listing has albums to go back to: those
+// ListAlbumsToMatch would hand it.
+func (q *Queries) CountAlbumsToMatch(ctx context.Context, arg CountAlbumsToMatchParams) (int64, error) {
+ row := q.db.QueryRow(ctx, countAlbumsToMatch, arg.Cutoff, arg.Matcher)
var count int64
err := row.Scan(&count)
return count, err
@@ -38,6 +53,10 @@ SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::big
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
COUNT(DISTINCT artist_mbid)::bigint AS artists,
+ -- Library albums waiting to be matched by hand (#5579).
+ (SELECT COUNT(*) FROM library_album_matches m
+ WHERE m.outcome = 'unmatched'
+ AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id))::bigint AS unmatched,
(SELECT COUNT(*) FROM follows)::bigint AS following,
-- The Wanted view's other tabs (#5522): downloads running, those
-- waiting on a person, and releases set aside.
@@ -54,6 +73,7 @@ type CountWantsRow struct {
Missing int64
Held int64
Artists int64
+ Unmatched int64
Following int64
Activity int64
Attention int64
@@ -68,6 +88,7 @@ func (q *Queries) CountWants(ctx context.Context) (CountWantsRow, error) {
&i.Missing,
&i.Held,
&i.Artists,
+ &i.Unmatched,
&i.Following,
&i.Activity,
&i.Attention,
@@ -143,7 +164,9 @@ func (q *Queries) DeleteWant(ctx context.Context, id pgtype.UUID) (int64, error)
const finishLibraryListing = `-- name: FinishLibraryListing :one
UPDATE acquisition_settings
SET library_listed_at = COALESCE(library_listed_at, now()),
- library_unlisted = (SELECT COUNT(*) FROM library_album_matches WHERE outcome = 'unmatched')
+ library_unlisted = (SELECT COUNT(*) FROM library_album_matches m
+ WHERE m.outcome = 'unmatched'
+ AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id))
WHERE id = true
RETURNING library_listed_at, library_unlisted
`
@@ -180,6 +203,38 @@ func (q *Queries) FulfillWant(ctx context.Context, arg FulfillWantParams) error
return err
}
+const getAlbumToMatch = `-- name: GetAlbumToMatch :one
+SELECT a.id, a.title, a.mbid, a.release_group_mbid, ar.name AS artist_name, ar.mbid AS artist_mbid
+ FROM albums a
+ JOIN artists ar ON ar.id = a.artist_id
+ JOIN library_album_matches m ON m.album_id = a.id AND m.outcome IN ('unmatched', 'dismissed')
+ WHERE a.id = $1
+`
+
+type GetAlbumToMatchRow struct {
+ ID pgtype.UUID
+ Title string
+ Mbid *string
+ ReleaseGroupMbid *string
+ ArtistName string
+ ArtistMbid *string
+}
+
+// One album a person is matching by hand, as the matcher reads albums.
+func (q *Queries) GetAlbumToMatch(ctx context.Context, id pgtype.UUID) (GetAlbumToMatchRow, error) {
+ row := q.db.QueryRow(ctx, getAlbumToMatch, id)
+ var i GetAlbumToMatchRow
+ err := row.Scan(
+ &i.ID,
+ &i.Title,
+ &i.Mbid,
+ &i.ReleaseGroupMbid,
+ &i.ArtistName,
+ &i.ArtistMbid,
+ )
+ return i, err
+}
+
const getDefaultReleaseTypeProfile = `-- name: GetDefaultReleaseTypeProfile :one
SELECT id, name, primary_types, secondary_types, is_default, created_at, updated_at FROM release_type_profiles WHERE is_default
`
@@ -438,10 +493,15 @@ const listAlbumsToMatch = `-- name: ListAlbumsToMatch :many
SELECT a.id, a.title, a.mbid, a.release_group_mbid, ar.name AS artist_name, ar.mbid AS artist_mbid
FROM albums a
JOIN artists ar ON ar.id = a.artist_id
- WHERE a.created_at <= $1::timestamptz
+ WHERE (a.created_at <= $1::timestamptz
+ OR EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.added_at <= $1::timestamptz))
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)
AND NOT EXISTS (SELECT 1 FROM library_album_matches m
- WHERE m.album_id = a.id AND (m.outcome = 'listed' OR m.matcher >= $2::int))
+ WHERE m.album_id = a.id
+ AND (m.outcome IN ('listed', 'dismissed')
+ OR (m.matcher >= $2::int
+ AND NOT EXISTS (SELECT 1 FROM tracks t
+ WHERE t.album_id = a.id AND t.updated_at > m.decided_at))))
ORDER BY a.created_at, a.id
LIMIT $3
`
@@ -461,9 +521,12 @@ type ListAlbumsToMatchRow struct {
ArtistMbid *string
}
-// Library albums from before the cutoff that the listing has not decided,
-// or that an older matcher refused: each is matched to MusicBrainz (#5569).
-// An album row with no tracks is not an album in the library.
+// Library albums the listing has not decided, or that an older matcher
+// refused, or whose tracks changed since they were refused: each is matched
+// to MusicBrainz (#5569). A library album is one from before the cutoff, or
+// one whose tracks were: a re-tag files a track under a new album row but
+// keeps the track (#5579). An album row with no tracks is not an album in
+// the library; one a person left off stays off.
func (q *Queries) ListAlbumsToMatch(ctx context.Context, arg ListAlbumsToMatchParams) ([]ListAlbumsToMatchRow, error) {
rows, err := q.db.Query(ctx, listAlbumsToMatch, arg.Cutoff, arg.Matcher, arg.MaxRows)
if err != nil {
@@ -813,12 +876,14 @@ func (q *Queries) ListReleaseTypeProfiles(ctx context.Context) ([]ListReleaseTyp
}
const listUnmatchedLibraryAlbums = `-- name: ListUnmatchedLibraryAlbums :many
-SELECT m.album_id, a.title, ar.name AS artist_name, m.detail
+SELECT m.album_id, a.title, ar.name AS artist_name, m.detail, m.outcome, m.decided_at,
+ (SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL)::int AS tracks
FROM library_album_matches m
JOIN albums a ON a.id = m.album_id
JOIN artists ar ON ar.id = a.artist_id
- WHERE m.outcome = 'unmatched'
- ORDER BY ar.name, a.title
+ WHERE m.outcome IN ('unmatched', 'dismissed')
+ AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id)
+ ORDER BY m.outcome DESC, lower(ar.name), lower(a.title)
LIMIT $1
`
@@ -827,9 +892,14 @@ type ListUnmatchedLibraryAlbumsRow struct {
Title string
ArtistName string
Detail string
+ Outcome string
+ DecidedAt pgtype.Timestamptz
+ Tracks int32
}
-// The albums the listing could not match, for the Held tab.
+// The Collection's Unmatched section (#5579): the albums the listing could
+// not match, and below them the ones a person left off. An album whose
+// tracks have all moved to another row is gone, not unmatched.
func (q *Queries) ListUnmatchedLibraryAlbums(ctx context.Context, maxRows int32) ([]ListUnmatchedLibraryAlbumsRow, error) {
rows, err := q.db.Query(ctx, listUnmatchedLibraryAlbums, maxRows)
if err != nil {
@@ -844,6 +914,9 @@ func (q *Queries) ListUnmatchedLibraryAlbums(ctx context.Context, maxRows int32)
&i.Title,
&i.ArtistName,
&i.Detail,
+ &i.Outcome,
+ &i.DecidedAt,
+ &i.Tracks,
); err != nil {
return nil, err
}
@@ -1179,6 +1252,21 @@ func (q *Queries) RecordListedAlbums(ctx context.Context, cutoff pgtype.Timestam
return err
}
+const recountLibraryUnlisted = `-- name: RecountLibraryUnlisted :exec
+UPDATE acquisition_settings
+ SET library_unlisted = (SELECT COUNT(*) FROM library_album_matches m
+ WHERE m.outcome = 'unmatched'
+ AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id))
+ WHERE id = true
+`
+
+// After a person matches or leaves off an album, the count the catalogue
+// shows.
+func (q *Queries) RecountLibraryUnlisted(ctx context.Context) error {
+ _, err := q.db.Exec(ctx, recountLibraryUnlisted)
+ return err
+}
+
const repointWant = `-- name: RepointWant :exec
UPDATE wants SET release_group_mbid = $2, updated_at = now() WHERE id = $1
`
@@ -1194,6 +1282,26 @@ func (q *Queries) RepointWant(ctx context.Context, arg RepointWantParams) error
return err
}
+const setAlbumMatchOutcome = `-- name: SetAlbumMatchOutcome :execrows
+UPDATE library_album_matches SET outcome = $1, decided_at = now()
+ WHERE album_id = $2 AND outcome = $3
+`
+
+type SetAlbumMatchOutcomeParams struct {
+ Outcome string
+ AlbumID pgtype.UUID
+ Was string
+}
+
+// Leave an unmatched album off the list, or put a left-off one back.
+func (q *Queries) SetAlbumMatchOutcome(ctx context.Context, arg SetAlbumMatchOutcomeParams) (int64, error) {
+ result, err := q.db.Exec(ctx, setAlbumMatchOutcome, arg.Outcome, arg.AlbumID, arg.Was)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
const setAlbumReleaseGroup = `-- name: SetAlbumReleaseGroup :exec
UPDATE albums SET release_group_mbid = $1, updated_at = now()
WHERE id = $2 AND (release_group_mbid IS NULL OR release_group_mbid = '')
diff --git a/internal/db/migrations/0089_library_match_by_hand.down.sql b/internal/db/migrations/0089_library_match_by_hand.down.sql
new file mode 100644
index 00000000..678f4eab
--- /dev/null
+++ b/internal/db/migrations/0089_library_match_by_hand.down.sql
@@ -0,0 +1,8 @@
+UPDATE library_album_matches SET outcome = 'unmatched' WHERE outcome = 'dismissed';
+UPDATE library_album_matches SET method = 'search' WHERE method = 'manual';
+ALTER TABLE library_album_matches DROP CONSTRAINT library_album_matches_outcome_check;
+ALTER TABLE library_album_matches ADD CONSTRAINT library_album_matches_outcome_check
+ CHECK (outcome IN ('listed', 'unmatched'));
+ALTER TABLE library_album_matches DROP CONSTRAINT library_album_matches_method_check;
+ALTER TABLE library_album_matches ADD CONSTRAINT library_album_matches_method_check
+ CHECK (method IN ('', 'tags', 'release', 'discography', 'search'));
diff --git a/internal/db/migrations/0089_library_match_by_hand.up.sql b/internal/db/migrations/0089_library_match_by_hand.up.sql
new file mode 100644
index 00000000..c093c221
--- /dev/null
+++ b/internal/db/migrations/0089_library_match_by_hand.up.sql
@@ -0,0 +1,10 @@
+-- The Collection's Unmatched section (#5579): a person matches an album the
+-- listing could not, by a MusicBrainz link or a search (method 'manual'),
+-- or leaves it off the list for good (outcome 'dismissed': a local band's
+-- album MusicBrainz will never have, a mix that is no album).
+ALTER TABLE library_album_matches DROP CONSTRAINT library_album_matches_outcome_check;
+ALTER TABLE library_album_matches ADD CONSTRAINT library_album_matches_outcome_check
+ CHECK (outcome IN ('listed', 'unmatched', 'dismissed'));
+ALTER TABLE library_album_matches DROP CONSTRAINT library_album_matches_method_check;
+ALTER TABLE library_album_matches ADD CONSTRAINT library_album_matches_method_check
+ CHECK (method IN ('', 'tags', 'release', 'discography', 'search', 'manual'));
diff --git a/internal/db/queries/wanted.sql b/internal/db/queries/wanted.sql
index 7aa34649..cd74b2c2 100644
--- a/internal/db/queries/wanted.sql
+++ b/internal/db/queries/wanted.sql
@@ -271,6 +271,10 @@ SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::big
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
COUNT(DISTINCT artist_mbid)::bigint AS artists,
+ -- Library albums waiting to be matched by hand (#5579).
+ (SELECT COUNT(*) FROM library_album_matches m
+ WHERE m.outcome = 'unmatched'
+ AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id))::bigint AS unmatched,
(SELECT COUNT(*) FROM follows)::bigint AS following,
-- The Wanted view's other tabs (#5522): downloads running, those
-- waiting on a person, and releases set aside.
@@ -330,23 +334,41 @@ SELECT a.id, 'listed', 'tags', a.release_group_mbid
ON CONFLICT (album_id) DO NOTHING;
-- name: ListAlbumsToMatch :many
--- Library albums from before the cutoff that the listing has not decided,
--- or that an older matcher refused: each is matched to MusicBrainz (#5569).
--- An album row with no tracks is not an album in the library.
+-- Library albums the listing has not decided, or that an older matcher
+-- refused, or whose tracks changed since they were refused: each is matched
+-- to MusicBrainz (#5569). A library album is one from before the cutoff, or
+-- one whose tracks were: a re-tag files a track under a new album row but
+-- keeps the track (#5579). An album row with no tracks is not an album in
+-- the library; one a person left off stays off.
SELECT a.id, a.title, a.mbid, a.release_group_mbid, ar.name AS artist_name, ar.mbid AS artist_mbid
FROM albums a
JOIN artists ar ON ar.id = a.artist_id
- WHERE a.created_at <= sqlc.arg(cutoff)::timestamptz
+ WHERE (a.created_at <= sqlc.arg(cutoff)::timestamptz
+ OR EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.added_at <= sqlc.arg(cutoff)::timestamptz))
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)
AND NOT EXISTS (SELECT 1 FROM library_album_matches m
- WHERE m.album_id = a.id AND (m.outcome = 'listed' OR m.matcher >= sqlc.arg(matcher)::int))
+ WHERE m.album_id = a.id
+ AND (m.outcome IN ('listed', 'dismissed')
+ OR (m.matcher >= sqlc.arg(matcher)::int
+ AND NOT EXISTS (SELECT 1 FROM tracks t
+ WHERE t.album_id = a.id AND t.updated_at > m.decided_at))))
ORDER BY a.created_at, a.id
LIMIT sqlc.arg(max_rows);
--- name: CountAlbumsToRematch :one
--- Refusals by an older matcher, which a finished listing goes back to.
-SELECT COUNT(*) FROM library_album_matches
- WHERE outcome = 'unmatched' AND matcher < sqlc.arg(matcher)::int;
+-- name: CountAlbumsToMatch :one
+-- Whether a finished listing has albums to go back to: those
+-- ListAlbumsToMatch would hand it.
+SELECT COUNT(*)
+ FROM albums a
+ WHERE (a.created_at <= sqlc.arg(cutoff)::timestamptz
+ OR EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.added_at <= sqlc.arg(cutoff)::timestamptz))
+ AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)
+ AND NOT EXISTS (SELECT 1 FROM library_album_matches m
+ WHERE m.album_id = a.id
+ AND (m.outcome IN ('listed', 'dismissed')
+ OR (m.matcher >= sqlc.arg(matcher)::int
+ AND NOT EXISTS (SELECT 1 FROM tracks t
+ WHERE t.album_id = a.id AND t.updated_at > m.decided_at))));
-- name: ListAlbumTracksToMatch :many
-- What an album's tracks say about themselves, to check a match against.
@@ -384,16 +406,45 @@ ON CONFLICT (album_id) DO UPDATE
-- count the albums no match was found for. The first finish is kept.
UPDATE acquisition_settings
SET library_listed_at = COALESCE(library_listed_at, now()),
- library_unlisted = (SELECT COUNT(*) FROM library_album_matches WHERE outcome = 'unmatched')
+ library_unlisted = (SELECT COUNT(*) FROM library_album_matches m
+ WHERE m.outcome = 'unmatched'
+ AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id))
WHERE id = true
RETURNING library_listed_at, library_unlisted;
-- name: ListUnmatchedLibraryAlbums :many
--- The albums the listing could not match, for the Held tab.
-SELECT m.album_id, a.title, ar.name AS artist_name, m.detail
+-- The Collection's Unmatched section (#5579): the albums the listing could
+-- not match, and below them the ones a person left off. An album whose
+-- tracks have all moved to another row is gone, not unmatched.
+SELECT m.album_id, a.title, ar.name AS artist_name, m.detail, m.outcome, m.decided_at,
+ (SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL)::int AS tracks
FROM library_album_matches m
JOIN albums a ON a.id = m.album_id
JOIN artists ar ON ar.id = a.artist_id
- WHERE m.outcome = 'unmatched'
- ORDER BY ar.name, a.title
+ WHERE m.outcome IN ('unmatched', 'dismissed')
+ AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id)
+ ORDER BY m.outcome DESC, lower(ar.name), lower(a.title)
LIMIT sqlc.arg(max_rows);
+
+-- name: RecountLibraryUnlisted :exec
+-- After a person matches or leaves off an album, the count the catalogue
+-- shows.
+UPDATE acquisition_settings
+ SET library_unlisted = (SELECT COUNT(*) FROM library_album_matches m
+ WHERE m.outcome = 'unmatched'
+ AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id))
+ WHERE id = true;
+
+-- name: GetAlbumToMatch :one
+-- One album a person is matching by hand, as the matcher reads albums.
+SELECT a.id, a.title, a.mbid, a.release_group_mbid, ar.name AS artist_name, ar.mbid AS artist_mbid
+ FROM albums a
+ JOIN artists ar ON ar.id = a.artist_id
+ JOIN library_album_matches m ON m.album_id = a.id AND m.outcome IN ('unmatched', 'dismissed')
+ WHERE a.id = $1;
+
+-- name: SetAlbumMatchOutcome :execrows
+-- Leave an unmatched album off the list, or put a left-off one back.
+UPDATE library_album_matches SET outcome = sqlc.arg(outcome), decided_at = now()
+ WHERE album_id = sqlc.arg(album_id) AND outcome = sqlc.arg(was);
+
diff --git a/internal/wanted/library.go b/internal/wanted/library.go
index 051fbbf1..9add993a 100644
--- a/internal/wanted/library.go
+++ b/internal/wanted/library.go
@@ -50,7 +50,10 @@ const libraryMatcher = 4
//
// Each album decided is recorded in library_album_matches, so an album taken
// off the list stays off. Once done the listing runs again only for albums
-// an older matcher refused (libraryMatcher).
+// an older matcher refused (libraryMatcher), refused albums whose tracks
+// have changed since, and library tracks a re-tag filed under a new album
+// (#5579). A person matches the rest by hand, or leaves them off, in the
+// Collection's Unmatched section (MatchAlbumTo, LeaveAlbumOff).
func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
q := dbq.New(s.pool)
state, err := q.GetLibraryListing(ctx)
@@ -58,9 +61,10 @@ func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
return fmt.Errorf("wanted: read the library listing: %w", err)
}
if state.LibraryListedAt.Valid {
- stale, err := q.CountAlbumsToRematch(ctx, libraryMatcher)
+ stale, err := q.CountAlbumsToMatch(ctx, dbq.CountAlbumsToMatchParams{
+ Cutoff: state.LibraryListingStartedAt, Matcher: libraryMatcher})
if err != nil {
- return fmt.Errorf("wanted: count refusals to retry: %w", err)
+ return fmt.Errorf("wanted: count albums to match again: %w", err)
}
if stale == 0 {
return nil
diff --git a/internal/wanted/manual.go b/internal/wanted/manual.go
new file mode 100644
index 00000000..3282cb64
--- /dev/null
+++ b/internal/wanted/manual.go
@@ -0,0 +1,171 @@
+package wanted
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/tags"
+)
+
+// Matching a library album by hand (#5579): the Collection's Unmatched
+// section, like Lidarr's "Unmapped Files". A person names the MusicBrainz
+// album an album is, by a link or a search, and it goes on the list as held;
+// or leaves it off for good, as a local band's album MusicBrainz will never
+// have.
+
+const matchManual = "manual"
+
+// Manual-match errors, which the API answers as 400, 404 or 409.
+var (
+ ErrMatchRef = errors.New("wanted: not a MusicBrainz album link or id")
+ ErrMatchNoGroup = errors.New("wanted: MusicBrainz has no such album")
+ ErrAlbumNotUnmatched = errors.New("wanted: no such unmatched album")
+)
+
+// ManualMatch is what matching an album by hand did: the group it is now,
+// and how many of its tracks agree with the group's closest edition, so the
+// person can see whether the link was the right one.
+type ManualMatch struct {
+ Group string
+ Title string
+ Artist string
+ Agree int
+ Tracks int
+}
+
+var mbidPattern = regexp.MustCompile(`(?i)(?:(release-group|release)/)?([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`)
+
+// ParseMusicBrainzRef reads a MusicBrainz link (a release group's or a
+// release's page) or a bare id. Kind is "release-group", "release", or ""
+// for a bare id, which is tried as a group first.
+func ParseMusicBrainzRef(ref string) (kind, id string, ok bool) {
+ m := mbidPattern.FindStringSubmatch(strings.TrimSpace(ref))
+ if m == nil {
+ return "", "", false
+ }
+ return strings.ToLower(m[1]), strings.ToLower(m[2]), true
+}
+
+// MatchAlbumTo puts an unmatched (or left-off) library album on the list as
+// held, as the MusicBrainz album ref names. The person's word is taken: the
+// tracks are compared only to say how well they agree.
+func (s *Service) MatchAlbumTo(ctx context.Context, albumID pgtype.UUID, ref string) (ManualMatch, error) {
+ kind, id, ok := ParseMusicBrainzRef(ref)
+ if !ok {
+ return ManualMatch{}, ErrMatchRef
+ }
+ q := dbq.New(s.pool)
+ a, err := q.GetAlbumToMatch(ctx, albumID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ManualMatch{}, ErrAlbumNotUnmatched
+ }
+ if err != nil {
+ return ManualMatch{}, fmt.Errorf("wanted: read album: %w", err)
+ }
+ group, err := s.groupOf(ctx, kind, id)
+ if err != nil {
+ return ManualMatch{}, err
+ }
+ g, err := s.cat.MusicBrainz().ReleaseGroup(ctx, group)
+ if errors.Is(err, musicbrainz.ErrNotFound) {
+ return ManualMatch{}, ErrMatchNoGroup
+ }
+ if err != nil {
+ return ManualMatch{}, err
+ }
+ artist := creditedArtist(g.ArtistMBIDs, deref(a.ArtistMbid))
+ if artist == "" {
+ return ManualMatch{}, fmt.Errorf("%w: it credits no artist", ErrMatchNoGroup)
+ }
+ rows, err := q.ListAlbumTracksToMatch(ctx, a.ID)
+ if err != nil {
+ return ManualMatch{}, fmt.Errorf("wanted: read %s's tracks: %w", a.Title, err)
+ }
+ tracks := tracksToMatch(rows)
+ n, _, _, err := s.agreementWith(ctx, group, tracks, func(string) bool { return true })
+ if err != nil {
+ return ManualMatch{}, err
+ }
+ err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
+ tq := dbq.New(tx)
+ if err := tq.SetAlbumReleaseGroup(ctx, dbq.SetAlbumReleaseGroupParams{ID: a.ID, ReleaseGroupMbid: &group}); err != nil {
+ return err
+ }
+ if _, err := tq.InsertLibraryWant(ctx, dbq.InsertLibraryWantParams{
+ ReleaseGroupMbid: group, ArtistMbid: artist, ArtistName: g.ArtistCredit, Title: g.Title,
+ PrimaryType: g.PrimaryType, FirstReleaseDate: g.FirstReleaseDate, AlbumID: a.ID,
+ }); err != nil {
+ return err
+ }
+ if err := tq.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{
+ AlbumID: a.ID, Outcome: "listed", Method: matchManual, ReleaseGroupMbid: group, Matcher: libraryMatcher,
+ }); err != nil {
+ return err
+ }
+ return tq.RecountLibraryUnlisted(ctx)
+ })
+ if err != nil {
+ return ManualMatch{}, fmt.Errorf("wanted: match %s by hand: %w", a.Title, err)
+ }
+ s.logger.Info("wanted: a library album matched by hand", "album", a.Title, "group", group, "agree", max(n, 0), "tracks", len(tracks))
+ return ManualMatch{Group: group, Title: g.Title, Artist: g.ArtistCredit, Agree: max(n, 0), Tracks: len(tracks)}, nil
+}
+
+// groupOf names the release group a MusicBrainz id is, or belongs to.
+func (s *Service) groupOf(ctx context.Context, kind, id string) (string, error) {
+ if kind != "release" {
+ _, err := s.cat.MusicBrainz().ReleaseGroup(ctx, id)
+ if err == nil {
+ return id, nil
+ }
+ if !errors.Is(err, musicbrainz.ErrNotFound) {
+ return "", err
+ }
+ if kind == "release-group" {
+ return "", ErrMatchNoGroup
+ }
+ }
+ group, err := s.releaseGroup(ctx, id)
+ switch {
+ case errors.Is(err, tags.ErrNotFound):
+ return "", ErrMatchNoGroup
+ case err != nil:
+ return "", err
+ case group == "":
+ return "", ErrMatchNoGroup
+ }
+ return group, nil
+}
+
+// LeaveAlbumOff leaves an unmatched library album off the list for good:
+// matching never tries it again, and it is no longer counted as unmatched.
+func (s *Service) LeaveAlbumOff(ctx context.Context, albumID pgtype.UUID) error {
+ return s.setMatchOutcome(ctx, albumID, "unmatched", "dismissed")
+}
+
+// PutAlbumBack undoes LeaveAlbumOff: the album is unmatched again.
+func (s *Service) PutAlbumBack(ctx context.Context, albumID pgtype.UUID) error {
+ return s.setMatchOutcome(ctx, albumID, "dismissed", "unmatched")
+}
+
+func (s *Service) setMatchOutcome(ctx context.Context, albumID pgtype.UUID, was, outcome string) error {
+ return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
+ tq := dbq.New(tx)
+ n, err := tq.SetAlbumMatchOutcome(ctx, dbq.SetAlbumMatchOutcomeParams{AlbumID: albumID, Was: was, Outcome: outcome})
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return ErrAlbumNotUnmatched
+ }
+ return tq.RecountLibraryUnlisted(ctx)
+ })
+}
diff --git a/internal/wanted/manual_test.go b/internal/wanted/manual_test.go
new file mode 100644
index 00000000..21ea8d5e
--- /dev/null
+++ b/internal/wanted/manual_test.go
@@ -0,0 +1,162 @@
+package wanted
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/jackc/pgx/v5/pgtype"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/tags"
+)
+
+func TestParseMusicBrainzRef(t *testing.T) {
+ const id = "11111111-0000-4000-8000-000000000003"
+ for ref, want := range map[string]string{
+ "https://musicbrainz.org/release-group/" + id: "release-group " + id,
+ "https://musicbrainz.org/release/" + id + "?tab=x": "release " + id,
+ " " + id + " ": " " + id,
+ "https://MusicBrainz.org/Release-Group/" + "11111111-0000-4000-8000-00000000000A": "release-group 11111111-0000-4000-8000-00000000000a",
+ } {
+ kind, got, ok := ParseMusicBrainzRef(ref)
+ if !ok || kind+" "+got != want {
+ t.Errorf("ParseMusicBrainzRef(%q) = %q %q %v, want %q", ref, kind, got, ok, want)
+ }
+ }
+ for _, bad := range []string{"", "Dial Tone", "https://musicbrainz.org/artist/nope"} {
+ if _, _, ok := ParseMusicBrainzRef(bad); ok {
+ t.Errorf("ParseMusicBrainzRef(%q) read an id", bad)
+ }
+ }
+}
+
+// A person names the MusicBrainz album an unmatched album is, and it goes
+// on the list as held; or leaves it off, and matching never tries it again
+// (#5579).
+func TestMatchAlbumTo_ByLinkOrLeaveItOff_Integration(t *testing.T) {
+ pool := newPool(t)
+ ctx := context.Background()
+ q := dbq.New(pool)
+ s := newService(t, pool, newFakeMB(t))
+ s.releaseGroup = func(context.Context, string) (string, error) { return "", tags.ErrNotFound }
+ split := seedAlbum(t, q, blur, "Blur + Friends", "", []string{"The Ballad", "St. Charles Square"})
+ local := seedAlbum(t, q, blur, "Dial Tone", "", []string{"Intro"})
+ for _, a := range []pgtype.UUID{split.ID, local.ID} {
+ if err := q.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{
+ AlbumID: a, Outcome: "unmatched", Detail: "no match", Matcher: libraryMatcher}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := pool.Exec(ctx, `UPDATE acquisition_settings
+ SET library_listing_started_at = now(), library_listed_at = now(), library_unlisted = 2`); err != nil {
+ t.Fatal(err)
+ }
+
+ for ref, want := range map[string]error{
+ "not a link": ErrMatchRef,
+ "https://musicbrainz.org/release-group/99999999-0000-4000-8000-000000000000": ErrMatchNoGroup,
+ "99999999-0000-4000-8000-000000000000": ErrMatchNoGroup,
+ } {
+ if _, err := s.MatchAlbumTo(ctx, split.ID, ref); !errors.Is(err, want) {
+ t.Errorf("MatchAlbumTo(%q) = %v, want %v", ref, err, want)
+ }
+ }
+
+ m, err := s.MatchAlbumTo(ctx, split.ID, "https://musicbrainz.org/release-group/"+theBallad)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m.Group != theBallad || m.Title != "The Ballad of Darren" || m.Agree != 2 || m.Tracks != 2 {
+ t.Errorf("match = %+v", m)
+ }
+ w, err := q.GetWantByReleaseGroup(ctx, theBallad)
+ if err != nil || w.Reason != ReasonLibrary || !w.FulfilledAt.Valid || w.AlbumID != split.ID || w.ArtistMbid != blur {
+ t.Fatalf("want = %+v, %v: held, about the album", w, err)
+ }
+ if outcome, method, group, _ := matchOf(t, pool, split.ID); outcome != "listed" || method != matchManual || group != theBallad {
+ t.Errorf("decided %s by %s as %s", outcome, method, group)
+ }
+ if state, _ := q.GetLibraryListing(ctx); state.LibraryUnlisted != 1 {
+ t.Errorf("unlisted = %d, want the local album alone", state.LibraryUnlisted)
+ }
+
+ // The local band's album: left off, it is not counted, and a newer
+ // matcher does not try it again.
+ if err := s.LeaveAlbumOff(ctx, local.ID); err != nil {
+ t.Fatal(err)
+ }
+ if outcome, _, _, _ := matchOf(t, pool, local.ID); outcome != "dismissed" {
+ t.Errorf("left off = %s", outcome)
+ }
+ if state, _ := q.GetLibraryListing(ctx); state.LibraryUnlisted != 0 {
+ t.Errorf("unlisted = %d after leaving it off", state.LibraryUnlisted)
+ }
+ if _, err := pool.Exec(ctx, `UPDATE library_album_matches SET matcher = 0 WHERE album_id = $1`, local.ID); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.listLibrary(ctx, &PassResult{}); err != nil {
+ t.Fatal(err)
+ }
+ if outcome, _, _, _ := matchOf(t, pool, local.ID); outcome != "dismissed" {
+ t.Errorf("an album left off was matched again: %s", outcome)
+ }
+ rows, err := q.ListUnmatchedLibraryAlbums(ctx, 10)
+ if err != nil || len(rows) != 1 || rows[0].Outcome != "dismissed" || rows[0].Tracks != 1 {
+ t.Errorf("unmatched section = %+v, %v: the left-off album alone", rows, err)
+ }
+
+ // Put back, it is unmatched again; putting it back twice is not.
+ if err := s.PutAlbumBack(ctx, local.ID); err != nil {
+ t.Fatal(err)
+ }
+ if outcome, _, _, _ := matchOf(t, pool, local.ID); outcome != "unmatched" {
+ t.Errorf("put back = %s", outcome)
+ }
+ if err := s.PutAlbumBack(ctx, local.ID); !errors.Is(err, ErrAlbumNotUnmatched) {
+ t.Errorf("put back twice: %v", err)
+ }
+}
+
+// A re-tag files a library track under a new album row. That album is the
+// library's, not one dropped in by hand, so the listing matches it; the
+// old row, its tracks gone, is no longer unmatched (#5579).
+func TestListLibrary_MatchesARetaggedAlbum_Integration(t *testing.T) {
+ pool := newPool(t)
+ ctx := context.Background()
+ q := dbq.New(pool)
+ s := newService(t, pool, newFakeMB(t))
+ untagged := seedAlbum(t, q, blur, "Unknown Album", "", []string{"The Ballad", "St. Charles Square"})
+ if err := q.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{
+ AlbumID: untagged.ID, Outcome: "unmatched", Detail: "no match", Matcher: libraryMatcher}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `
+ UPDATE albums SET created_at = now() - interval '2 hours' WHERE id = $1;
+ UPDATE tracks SET added_at = now() - interval '2 hours' WHERE album_id = $1;
+ UPDATE acquisition_settings SET library_listing_started_at = now() - interval '1 hour',
+ library_listed_at = now() - interval '1 hour', library_unlisted = 1`, untagged.ID); err != nil {
+ t.Fatal(err)
+ }
+ // The re-tag: the same tracks, under the album their new tags name.
+ retagged, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "The Ballad of Darren", SortTitle: "The Ballad of Darren", ArtistID: untagged.ArtistID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `UPDATE tracks SET album_id = $1, updated_at = now() WHERE album_id = $2`, retagged.ID, untagged.ID); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.listLibrary(ctx, &PassResult{}); err != nil {
+ t.Fatal(err)
+ }
+ if outcome, method, group, _ := matchOf(t, pool, retagged.ID); outcome != "listed" || method != matchDiscography || group != theBallad {
+ t.Errorf("the re-tagged album was decided %s by %s as %s", outcome, method, group)
+ }
+ if rows, _ := q.ListUnmatchedLibraryAlbums(ctx, 10); len(rows) != 0 {
+ t.Errorf("unmatched = %+v: the old row has no tracks left", rows)
+ }
+ if state, _ := q.GetLibraryListing(ctx); state.LibraryUnlisted != 0 {
+ t.Errorf("unlisted = %d", state.LibraryUnlisted)
+ }
+}
diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts
index 4dbde645..0859f3c5 100644
--- a/web/src/lib/api/queries.ts
+++ b/web/src/lib/api/queries.ts
@@ -45,6 +45,8 @@ export const qk = {
['adminWants', { state, q, artist }] as const,
wantArtists: (q: string = '') => ['adminWants', 'artists', { q }] as const,
wantTracks: (id: string) => ['wantTracks', id] as const,
+ // The Collection's Unmatched section (#5579).
+ unmatched: () => ['adminWants', 'unmatched'] as const,
adminFollows: () => ['adminFollows'] as const,
adminFollow: (artistMbid: string) => ['adminFollow', artistMbid] as const,
releaseProfiles: () => ['releaseProfiles'] as const,
diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts
index 3684ccb3..1b29cb5a 100644
--- a/web/src/lib/api/types.ts
+++ b/web/src/lib/api/types.ts
@@ -733,6 +733,9 @@ export type WantCounts = {
activity: number;
attention: number;
blocked: number;
+ // Library albums no MusicBrainz album matched, waiting to be matched by
+ // hand or left off (#5579).
+ unmatched: number;
};
// What an import's check counted (#5522).
@@ -818,9 +821,23 @@ export type ReleaseBlocksPage = { items: ReleaseBlock[]; limit: number; offset:
// The one-time listing of the library's albums as held (#5567): when it
// finished (null while it runs), and how many albums nothing names to
// MusicBrainz, so they could not be listed.
-export type UnmatchedAlbum = { album_id: string; title: string; artist: string; detail: string };
+// One album in the Collection's Unmatched section (#5579): why no
+// MusicBrainz album matched it, or that a person left it off.
+export type UnmatchedAlbum = {
+ album_id: string;
+ title: string;
+ artist: string;
+ detail: string;
+ tracks: number;
+ left_off: boolean;
+ decided_at: string;
+};
-export type LibraryListing = { listed_at: string | null; unlisted: number; unmatched: UnmatchedAlbum[] };
+// What matching an album by hand did: the MusicBrainz album it is now, and
+// how many of its tracks agree with that album's closest edition.
+export type ManualMatch = { release_group_mbid: string; title: string; artist: string; agree: number; tracks: number };
+
+export type LibraryListing = { listed_at: string | null; unlisted: number };
// artist heads the list when it is narrowed to one artist's page.
export type WantsPage = Page & { counts: WantCounts; library: LibraryListing; artist?: WantArtist };
diff --git a/web/src/lib/api/wanted.ts b/web/src/lib/api/wanted.ts
index d5a60f9f..e2b59706 100644
--- a/web/src/lib/api/wanted.ts
+++ b/web/src/lib/api/wanted.ts
@@ -11,8 +11,10 @@ import type {
ReleaseProfileInput,
ReleaseProfilesResponse,
GrabResult,
+ ManualMatch,
SearchResult,
SearchSettings,
+ UnmatchedAlbum,
WantArtistsPage,
WantState,
WantTracks,
@@ -92,6 +94,37 @@ export function createWantTracksQuery(id: string) {
});
}
+// The Collection's Unmatched section (#5579): library albums no MusicBrainz
+// album matched, and after them the ones a person left off.
+export async function listUnmatched(): Promise {
+ const out = await api.get<{ items: UnmatchedAlbum[] }>('/api/admin/wanted/unmatched');
+ return out.items;
+}
+
+export function createUnmatchedQuery() {
+ return createQuery({
+ queryKey: qk.unmatched(),
+ queryFn: listUnmatched,
+ staleTime: 30_000
+ });
+}
+
+// Puts an unmatched album on the list as held, as the MusicBrainz album ref
+// names: a release group's or a release's link, or a bare id.
+export async function matchUnmatched(albumId: string, ref: string): Promise {
+ return api.post(`/api/admin/wanted/unmatched/${albumId}/match`, { ref });
+}
+
+// Leaves an album off for good (a local band's, which MusicBrainz will
+// never have), or puts one left off back.
+export async function leaveUnmatchedOff(albumId: string): Promise {
+ await api.post(`/api/admin/wanted/unmatched/${albumId}/leave-off`, {});
+}
+
+export async function putUnmatchedBack(albumId: string): Promise {
+ await api.post(`/api/admin/wanted/unmatched/${albumId}/put-back`, {});
+}
+
export async function removeWant(id: string): Promise {
await api.del(`/api/admin/wanted/${id}`);
}
diff --git a/web/src/lib/components/wanted/CatalogueGrid.svelte b/web/src/lib/components/wanted/CatalogueGrid.svelte
index f37478c2..5409a4fd 100644
--- a/web/src/lib/components/wanted/CatalogueGrid.svelte
+++ b/web/src/lib/components/wanted/CatalogueGrid.svelte
@@ -1,5 +1,5 @@
{#if library && !library.listed_at}
@@ -29,41 +27,6 @@
Your library's albums are being added to the catalogue. Albums whose tags don't name them are
matched on MusicBrainz first, by their artist, title and tracks, about one a second.
-{:else if library && library.unlisted > 0}
-
-
- {library.unlisted === 1 ? '1 album' : `${library.unlisted} albums`} in your library
- {library.unlisted === 1 ? "isn't" : "aren't"} in the catalogue: no MusicBrainz album matched
- {library.unlisted === 1 ? 'its' : 'their'} artist, title and tracks. Until
- {library.unlisted === 1 ? "it's" : "they're"} matched, {library.unlisted === 1 ? "it isn't" : "they aren't"}
- fetched back if {library.unlisted === 1 ? 'its' : 'their'} files go missing.
-
The first {library.unmatched.length} of {library.unlisted}.
- {/if}
- {/if}
- {/if}
-
{/if}
{#if !artists.isPending && (items.length > 0 || q)}
diff --git a/web/src/lib/components/wanted/CatalogueGrid.test.ts b/web/src/lib/components/wanted/CatalogueGrid.test.ts
index 3b4bc138..cceb1cdc 100644
--- a/web/src/lib/components/wanted/CatalogueGrid.test.ts
+++ b/web/src/lib/components/wanted/CatalogueGrid.test.ts
@@ -32,14 +32,14 @@ function setup(
artist('blur', 'Blur', { following: true, downloading: 1 }),
artist('beatles', 'The Beatles', { sort_name: 'Beatles, The', albums: 13, held: 13, wanted: 0 })
],
- library: LibraryListing = { listed_at: ago(DAY), unlisted: 0, unmatched: [] }
+ library: LibraryListing = { listed_at: ago(DAY), unlisted: 0 }
) {
const page: WantArtistsPage = {
items,
total: items.length,
limit: 96,
offset: 0,
- counts: { artists: items.length, wanted: 1, missing: 0, held: 16, following: 1, activity: 1, attention: 0, blocked: 0 },
+ counts: { artists: items.length, wanted: 1, missing: 0, held: 16, following: 1, activity: 1, attention: 0, blocked: 0, unmatched: 0 },
library
};
vi.mocked(createWantArtistsQuery).mockReturnValue(
@@ -80,24 +80,7 @@ describe('CatalogueGrid', () => {
});
test('says while the library is being listed', () => {
- setup([], { listed_at: null, unlisted: 0, unmatched: [] });
+ setup([], { listed_at: null, unlisted: 0 });
expect(screen.getByTestId('library-listing').textContent).toMatch(/being added to the catalogue/i);
});
-
- test('names the albums no MusicBrainz album matched, on request', async () => {
- setup(undefined, {
- listed_at: ago(DAY),
- unlisted: 12,
- unmatched: [
- { album_id: 'al-9', title: 'Demo Tape', artist: 'Blur', detail: 'MusicBrainz has no album called "Demo Tape" by Blur.' }
- ]
- });
- expect(screen.getByTestId('library-listing').textContent).toMatch(/12 albums in your library\s+aren't in the catalogue/i);
- expect(screen.queryByTestId('unmatched-albums')).toBeNull();
- await fireEvent.click(screen.getByRole('button', { name: /show them/i }));
- const list = screen.getByTestId('unmatched-albums');
- expect(list.textContent).toMatch(/Demo Tape/);
- expect(screen.getByRole('link', { name: 'Demo Tape' }).getAttribute('href')).toBe('/albums/al-9');
- expect(screen.getByText(/the first 1 of 12/i)).toBeTruthy();
- });
});
diff --git a/web/src/lib/components/wanted/CollectionRail.svelte b/web/src/lib/components/wanted/CollectionRail.svelte
index a2d2e52b..6f58c691 100644
--- a/web/src/lib/components/wanted/CollectionRail.svelte
+++ b/web/src/lib/components/wanted/CollectionRail.svelte
@@ -5,7 +5,9 @@
// The Collection's sections, down the left like Lidarr's (#5572): the
// catalogue of artists first, then the sections that show state. On a
// narrow screen the rail runs across the top. Activity carries a second,
- // accented count when downloads are waiting on a person.
+ // accented count when downloads are waiting on a person. Unmatched, like
+ // Lidarr's Unmapped Files, holds the library albums no MusicBrainz album
+ // matched (#5579).
let { counts }: { counts?: WantCounts } = $props();
@@ -17,7 +19,8 @@
{ href: '/collection/missing', label: 'Missing files', count: counts?.missing },
{ href: '/collection/activity', label: 'Activity', count: counts?.activity, attention: counts?.attention },
{ href: '/collection/following', label: 'Following', count: counts?.following },
- { href: '/collection/blocked', label: 'Blocked', count: counts?.blocked }
+ { href: '/collection/blocked', label: 'Blocked', count: counts?.blocked },
+ { href: '/collection/unmatched', label: 'Unmatched', count: counts?.unmatched }
]);
// An artist's page belongs to the catalogue.
diff --git a/web/src/lib/components/wanted/UnmatchedList.svelte b/web/src/lib/components/wanted/UnmatchedList.svelte
new file mode 100644
index 00000000..85d6ce19
--- /dev/null
+++ b/web/src/lib/components/wanted/UnmatchedList.svelte
@@ -0,0 +1,150 @@
+
+
+{#if unmatched.isPending}
+
Reading the unmatched albums…
+{:else if unmatched.isError}
+
Couldn't load the unmatched albums.
+{:else}
+ {#if toMatch.length === 0}
+
+
+
Every album in your library is matched.
+
+ An album no MusicBrainz album matches by its artist, title and tracks lands here, to be
+ matched by hand or left off.
+
+
+ {:else}
+
+ No MusicBrainz album matched {toMatch.length === 1 ? 'this album' : `these ${toMatch.length} albums`}
+ by artist, title and tracks, so {toMatch.length === 1 ? "it isn't" : "they aren't"} fetched back if
+ {toMatch.length === 1 ? 'its' : 'their'} files go missing. Match each to its MusicBrainz album, or
+ leave it off if MusicBrainz doesn't have it.
+