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)
|
||||
|
||||
+124
-16
@@ -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 = '')
|
||||
|
||||
@@ -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'));
|
||||
@@ -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'));
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Want> & { counts: WantCounts; library: LibraryListing; artist?: WantArtist };
|
||||
|
||||
@@ -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<UnmatchedAlbum[]> {
|
||||
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<ManualMatch> {
|
||||
return api.post<ManualMatch>(`/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<void> {
|
||||
await api.post<void>(`/api/admin/wanted/unmatched/${albumId}/leave-off`, {});
|
||||
}
|
||||
|
||||
export async function putUnmatchedBack(albumId: string): Promise<void> {
|
||||
await api.post<void>(`/api/admin/wanted/unmatched/${albumId}/put-back`, {});
|
||||
}
|
||||
|
||||
export async function removeWant(id: string): Promise<void> {
|
||||
await api.del(`/api/admin/wanted/${id}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, ListChecks } from 'lucide-svelte';
|
||||
import { ListChecks } from 'lucide-svelte';
|
||||
import { createWantArtistsQuery } from '#lib/api/wanted.js';
|
||||
import AlphabeticalGrid from '#lib/components/AlphabeticalGrid.svelte';
|
||||
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
||||
@@ -12,7 +12,7 @@
|
||||
// artist on the list A–Z, each card showing how many of their albums are
|
||||
// held. An artist opens on their albums. The library's albums were put on
|
||||
// the list as held once (#5567), each matched to MusicBrainz (#5569); the
|
||||
// ones no match was found for are named here.
|
||||
// ones no match was found for are listed under Unmatched (#5579).
|
||||
|
||||
let filter = $state('');
|
||||
const q = $derived(filter.trim());
|
||||
@@ -20,8 +20,6 @@
|
||||
const artists = $derived($artistsStore);
|
||||
const items = $derived((artists.data?.pages ?? []).flatMap((p) => p.items) as WantArtist[]);
|
||||
const library = $derived(artists.data?.pages[0]?.library);
|
||||
|
||||
let showUnmatched = $state(false);
|
||||
</script>
|
||||
|
||||
{#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.
|
||||
</p>
|
||||
{:else if library && library.unlisted > 0}
|
||||
<div class="mb-3 rounded-lg border border-border bg-surface p-3 text-sm" data-testid="library-listing">
|
||||
<p class="text-text-primary">
|
||||
{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.
|
||||
</p>
|
||||
{#if library.unmatched.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 inline-flex items-center gap-1 text-xs text-text-secondary hover:text-text-primary"
|
||||
aria-expanded={showUnmatched}
|
||||
onclick={() => (showUnmatched = !showUnmatched)}
|
||||
>
|
||||
<ChevronDown size={12} strokeWidth={2} class={showUnmatched ? 'rotate-180' : ''} />
|
||||
{showUnmatched ? 'Hide them' : 'Show them'}
|
||||
</button>
|
||||
{#if showUnmatched}
|
||||
<ul class="mt-2 space-y-2" data-testid="unmatched-albums">
|
||||
{#each library.unmatched as u (u.album_id)}
|
||||
<li>
|
||||
<a href="/albums/{u.album_id}" class="text-text-primary hover:underline">{u.title}</a>
|
||||
<span class="text-text-secondary">· {u.artist}</span>
|
||||
<div class="text-xs text-text-muted">{u.detail}</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if library.unmatched.length < library.unlisted}
|
||||
<p class="mt-2 text-xs text-text-muted">The first {library.unmatched.length} of {library.unlisted}.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !artists.isPending && (items.length > 0 || q)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { CircleCheck } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { createUnmatchedQuery, leaveUnmatchedOff, matchUnmatched, putUnmatchedBack } from '#lib/api/wanted.js';
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import UnmatchedMatch from './UnmatchedMatch.svelte';
|
||||
import type { UnmatchedAlbum } from '#lib/api/types.js';
|
||||
|
||||
// The Collection's Unmatched section (#5579), like Lidarr's Unmapped
|
||||
// Files: the library albums no MusicBrainz album matched by artist, title
|
||||
// and tracks. Each can be matched by hand, to a search result or a pasted
|
||||
// link, and goes on the list as held; or left off, as a local band's album
|
||||
// MusicBrainz will never have. Until then an album isn't fetched back if
|
||||
// its files go missing.
|
||||
|
||||
const client = useQueryClient();
|
||||
const unmatchedStore = createUnmatchedQuery();
|
||||
const unmatched = $derived($unmatchedStore);
|
||||
const all = $derived((unmatched.data ?? []) as UnmatchedAlbum[]);
|
||||
const toMatch = $derived(all.filter((u) => !u.left_off));
|
||||
const leftOff = $derived(all.filter((u) => u.left_off));
|
||||
|
||||
let open = $state<string | null>(null);
|
||||
let busy = $state<string | null>(null);
|
||||
|
||||
async function act(u: UnmatchedAlbum, run: () => Promise<string>) {
|
||||
busy = u.album_id;
|
||||
try {
|
||||
pushToast(await run());
|
||||
open = null;
|
||||
await client.invalidateQueries({ queryKey: ['adminWants'] });
|
||||
} catch (e) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
const onMatch = (u: UnmatchedAlbum, ref: string) =>
|
||||
act(u, async () => {
|
||||
const m = await matchUnmatched(u.album_id, ref);
|
||||
const agree = m.tracks ? ` ${m.agree} of ${m.tracks} tracks agree.` : '';
|
||||
return `${u.title} is now ${m.title} by ${m.artist}, held.${agree}`;
|
||||
});
|
||||
|
||||
const onLeaveOff = (u: UnmatchedAlbum) =>
|
||||
act(u, async () => {
|
||||
await leaveUnmatchedOff(u.album_id);
|
||||
return `${u.title} is left off the list.`;
|
||||
});
|
||||
|
||||
const onPutBack = (u: UnmatchedAlbum) =>
|
||||
act(u, async () => {
|
||||
await putUnmatchedBack(u.album_id);
|
||||
return `${u.title} is back to be matched.`;
|
||||
});
|
||||
|
||||
const tracks = (n: number) => (n === 1 ? '1 track' : `${n} tracks`);
|
||||
</script>
|
||||
|
||||
{#if unmatched.isPending}
|
||||
<p class="text-text-secondary">Reading the unmatched albums…</p>
|
||||
{:else if unmatched.isError}
|
||||
<p class="text-error-fg">Couldn't load the unmatched albums.</p>
|
||||
{:else}
|
||||
{#if toMatch.length === 0}
|
||||
<div class="rounded-lg border border-border bg-surface p-6 text-center">
|
||||
<CircleCheck size={28} strokeWidth={1} class="mx-auto text-text-muted" />
|
||||
<p class="mt-3 text-text-primary">Every album in your library is matched.</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
An album no MusicBrainz album matches by its artist, title and tracks lands here, to be
|
||||
matched by hand or left off.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mb-3 text-sm text-text-secondary">
|
||||
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.
|
||||
</p>
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{#each toMatch as u (u.album_id)}
|
||||
<li class="p-3" data-testid="unmatched-row">
|
||||
<div class="flex flex-wrap items-start gap-3">
|
||||
<div class="min-w-0 flex-1 space-y-0.5">
|
||||
<a href="/albums/{u.album_id}" class="block truncate text-sm font-medium text-text-primary hover:underline"
|
||||
>{u.title}</a
|
||||
>
|
||||
<div class="truncate text-xs text-text-secondary">{u.artist} · {tracks(u.tracks)}</div>
|
||||
<p class="text-xs text-text-muted">{u.detail}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open === u.album_id}
|
||||
onclick={() => (open = open === u.album_id ? null : u.album_id)}
|
||||
class="rounded-md border border-border px-2.5 py-1 text-xs text-text-primary hover:bg-surface-hover"
|
||||
>
|
||||
Match…
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === u.album_id}
|
||||
onclick={() => onLeaveOff(u)}
|
||||
class="rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
Leave it off
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if open === u.album_id}
|
||||
<UnmatchedMatch album={u} busy={busy === u.album_id} onMatch={(ref) => onMatch(u, ref)} />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if leftOff.length > 0}
|
||||
<section class="mt-6" data-testid="left-off">
|
||||
<h2 class="mb-2 text-sm font-medium text-text-primary">Left off</h2>
|
||||
<p class="mb-3 text-xs text-text-secondary">
|
||||
Kept in the library, never matched again, and not fetched back if their files go missing.
|
||||
</p>
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{#each leftOff as u (u.album_id)}
|
||||
<li class="flex flex-wrap items-center gap-3 p-3" data-testid="left-off-row">
|
||||
<div class="min-w-0 flex-1 space-y-0.5">
|
||||
<a href="/albums/{u.album_id}" class="block truncate text-sm text-text-primary hover:underline">{u.title}</a>
|
||||
<div class="truncate text-xs text-text-secondary">
|
||||
{u.artist} · {tracks(u.tracks)} · left off {relativeTime(u.decided_at)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy === u.album_id}
|
||||
onclick={() => onPutBack(u)}
|
||||
class="shrink-0 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
Put back
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import type { CatalogueSearchResult, UnmatchedAlbum } from '#lib/api/types.js';
|
||||
|
||||
vi.mock('#lib/api/wanted.js', async () => {
|
||||
const { mockQuery } = await import('../../../test-utils/query');
|
||||
return {
|
||||
createUnmatchedQuery: vi.fn(() => mockQuery({ data: [] })),
|
||||
matchUnmatched: vi.fn().mockResolvedValue({
|
||||
release_group_mbid: 'rg-1',
|
||||
title: 'Songs for the Deaf',
|
||||
artist: 'Queens of the Stone Age',
|
||||
agree: 9,
|
||||
tracks: 10
|
||||
}),
|
||||
leaveUnmatchedOff: vi.fn().mockResolvedValue(undefined),
|
||||
putUnmatchedBack: vi.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
});
|
||||
vi.mock('#lib/api/catalogue.js', () => ({ createCatalogueSearchQuery: vi.fn() }));
|
||||
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
import UnmatchedList from './UnmatchedList.svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import { createUnmatchedQuery, leaveUnmatchedOff, matchUnmatched, putUnmatchedBack } from '#lib/api/wanted.js';
|
||||
import { createCatalogueSearchQuery } from '#lib/api/catalogue.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
|
||||
const DAY = 24 * 3_600_000;
|
||||
|
||||
function album(id: string, title: string, artist: string, extra: Partial<UnmatchedAlbum> = {}): UnmatchedAlbum {
|
||||
return {
|
||||
album_id: id,
|
||||
title,
|
||||
artist,
|
||||
detail: `MusicBrainz has no album called "${title}" by ${artist}.`,
|
||||
tracks: 10,
|
||||
left_off: false,
|
||||
decided_at: new Date(Date.now() - 2 * DAY).toISOString(),
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
const result: CatalogueSearchResult = {
|
||||
mbid: 'rg-1',
|
||||
name: 'Songs for the Deaf',
|
||||
secondary_text: 'Queens of the Stone Age · 2002 · Album',
|
||||
image_url: '',
|
||||
artist_mbid: 'qotsa',
|
||||
album_mbid: '',
|
||||
in_library: false,
|
||||
requested: false
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function setup(items: UnmatchedAlbum[], results: CatalogueSearchResult[] = [result]) {
|
||||
vi.mocked(createUnmatchedQuery).mockReturnValue(
|
||||
mockQuery({ data: items }) as unknown as ReturnType<typeof createUnmatchedQuery>
|
||||
);
|
||||
vi.mocked(createCatalogueSearchQuery).mockReturnValue(
|
||||
mockQuery({ data: results }) as unknown as ReturnType<typeof createCatalogueSearchQuery>
|
||||
);
|
||||
return render(UnmatchedList);
|
||||
}
|
||||
|
||||
describe('UnmatchedList', () => {
|
||||
test('each unmatched album says why, with its tracks, and links to the album', () => {
|
||||
setup([album('al-1', 'Dial Tone', 'Annadale', { tracks: 1 })]);
|
||||
const row = screen.getByTestId('unmatched-row');
|
||||
expect(row.textContent).toMatch(/Annadale · 1 track/);
|
||||
expect(row.textContent).toMatch(/no album called "Dial Tone"/);
|
||||
expect(screen.getByRole('link', { name: 'Dial Tone' }).getAttribute('href')).toBe('/albums/al-1');
|
||||
});
|
||||
|
||||
test('Match searches MusicBrainz for the album by its own title and artist, and matches a result', async () => {
|
||||
setup([album('al-2', 'Songs for the Deaf', 'Queens of the Stone Age')]);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /match…/i }));
|
||||
expect(createCatalogueSearchQuery).toHaveBeenLastCalledWith('Songs for the Deaf Queens of the Stone Age', 'album');
|
||||
await fireEvent.click(screen.getByRole('button', { name: /match to songs for the deaf/i }));
|
||||
await waitFor(() => expect(matchUnmatched).toHaveBeenCalledWith('al-2', 'rg-1'));
|
||||
await waitFor(() => expect(pushToast).toHaveBeenCalledWith(expect.stringMatching(/9 of 10 tracks agree/)));
|
||||
});
|
||||
|
||||
test('a pasted MusicBrainz link matches the album', async () => {
|
||||
setup([album('al-3', '+++', 'DROELOE')]);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /match…/i }));
|
||||
const link = 'https://musicbrainz.org/release/11111111-0000-4000-8000-000000000003';
|
||||
await fireEvent.input(screen.getByRole('textbox', { name: /musicbrainz album link/i }), { target: { value: link } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^match$/i }));
|
||||
await waitFor(() => expect(matchUnmatched).toHaveBeenCalledWith('al-3', link));
|
||||
});
|
||||
|
||||
test("a local band's album is left off, and one left off can be put back", async () => {
|
||||
setup([album('al-1', 'Dial Tone', 'Annadale'), album('al-4', 'Demo', 'Annadale', { left_off: true })]);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /leave it off/i }));
|
||||
await waitFor(() => expect(leaveUnmatchedOff).toHaveBeenCalledWith('al-1'));
|
||||
const leftOff = screen.getByTestId('left-off-row');
|
||||
expect(leftOff.textContent).toMatch(/left off 2d ago/);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /put back/i }));
|
||||
await waitFor(() => expect(putUnmatchedBack).toHaveBeenCalledWith('al-4'));
|
||||
});
|
||||
|
||||
test('with every album matched it says so, and still lists those left off', () => {
|
||||
setup([album('al-4', 'Demo', 'Annadale', { left_off: true })]);
|
||||
expect(screen.getByText(/every album in your library is matched/i)).toBeTruthy();
|
||||
expect(screen.getByTestId('left-off')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { Search } from 'lucide-svelte';
|
||||
import { createCatalogueSearchQuery } from '#lib/api/catalogue.js';
|
||||
import type { CatalogueSearchResult, UnmatchedAlbum } from '#lib/api/types.js';
|
||||
|
||||
// Naming the MusicBrainz album an unmatched album is (#5579): a search of
|
||||
// MusicBrainz's albums, started from the album's own title and artist, or
|
||||
// a link pasted from musicbrainz.org. MusicBrainz takes a request a second,
|
||||
// so the search runs when it's asked for, not on each keystroke.
|
||||
|
||||
let {
|
||||
album,
|
||||
busy = false,
|
||||
onMatch
|
||||
}: { album: UnmatchedAlbum; busy?: boolean; onMatch: (ref: string) => void } = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let text = $state(`${album.title} ${album.artist}`.trim());
|
||||
// svelte-ignore state_referenced_locally
|
||||
let asked = $state(`${album.title} ${album.artist}`.trim());
|
||||
let link = $state('');
|
||||
|
||||
const searchStore = $derived(createCatalogueSearchQuery(asked, 'album'));
|
||||
const search = $derived($searchStore);
|
||||
const results = $derived((search.data ?? []) as CatalogueSearchResult[]);
|
||||
</script>
|
||||
|
||||
<div class="mt-3 space-y-3 rounded-md border border-border bg-bg p-3" data-testid="unmatched-match">
|
||||
<form
|
||||
class="flex gap-2"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
asked = text.trim();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={text}
|
||||
aria-label="Search MusicBrainz albums"
|
||||
class="min-w-0 flex-1 rounded-md border border-border bg-surface px-2.5 py-1.5 text-sm text-text-primary placeholder:text-text-muted"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary"
|
||||
>
|
||||
<Search size={14} strokeWidth={1.75} aria-hidden="true" /> Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if search.isPending && asked}
|
||||
<p class="text-xs text-text-secondary">Asking MusicBrainz…</p>
|
||||
{:else if search.isError}
|
||||
<p class="text-xs text-error-fg">Couldn't reach MusicBrainz. A pasted link works the same.</p>
|
||||
{:else if asked && results.length === 0}
|
||||
<p class="text-xs text-text-secondary">MusicBrainz has no album like that.</p>
|
||||
{:else if results.length > 0}
|
||||
<ul class="divide-y divide-border">
|
||||
{#each results as r (r.mbid)}
|
||||
<li class="flex items-center gap-3 py-2" data-testid="unmatched-result">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm text-text-primary">{r.name}</div>
|
||||
<div class="truncate text-xs text-text-secondary">{r.secondary_text}</div>
|
||||
</div>
|
||||
<a
|
||||
href="https://musicbrainz.org/release-group/{r.mbid}"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="shrink-0 text-xs text-text-secondary hover:text-text-primary hover:underline">MusicBrainz</a
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onclick={() => onMatch(r.mbid)}
|
||||
aria-label="Match to {r.name}"
|
||||
class="shrink-0 rounded-md border border-border px-2.5 py-1 text-xs text-text-primary hover:bg-surface-hover disabled:opacity-50"
|
||||
>
|
||||
This one
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
class="flex gap-2 border-t border-border pt-3"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (link.trim()) onMatch(link.trim());
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={link}
|
||||
placeholder="Or paste a MusicBrainz album link"
|
||||
aria-label="MusicBrainz album link"
|
||||
class="min-w-0 flex-1 rounded-md border border-border bg-surface px-2.5 py-1.5 text-sm text-text-primary placeholder:text-text-muted"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !link.trim()}
|
||||
class="shrink-0 rounded-md border border-border px-2.5 py-1 text-xs text-text-primary hover:bg-surface-hover disabled:opacity-50"
|
||||
>
|
||||
Match
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -54,7 +54,7 @@ function want(id: string, extra: Partial<Want> = {}): Want {
|
||||
|
||||
function page(
|
||||
items: Want[],
|
||||
library: LibraryListing = { listed_at: ago(DAY), unlisted: 0, unmatched: [] },
|
||||
library: LibraryListing = { listed_at: ago(DAY), unlisted: 0 },
|
||||
artist?: WantsPage['artist']
|
||||
): WantsPage {
|
||||
return {
|
||||
@@ -63,7 +63,7 @@ function page(
|
||||
total: items.length,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
counts: { artists: 2, wanted: 2, missing: 1, held: 4, following: 1, activity: 1, attention: 0, blocked: 0 },
|
||||
counts: { artists: 2, wanted: 2, missing: 1, held: 4, following: 1, activity: 1, attention: 0, blocked: 0, unmatched: 0 },
|
||||
library
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,8 +21,14 @@
|
||||
}
|
||||
const parts = [counts.wanted === 1 ? '1 album wanted' : `${counts.wanted} albums wanted`];
|
||||
if (counts.activity) parts.push(`${counts.activity} downloading`);
|
||||
// Library albums no MusicBrainz album matched wait on a person too (#5579).
|
||||
if (counts.unmatched) {
|
||||
const n = counts.unmatched === 1 ? '1 album in your library needs' : `${counts.unmatched} albums in your library need`;
|
||||
return parts.join(', ') + `. ${n} matching.`;
|
||||
}
|
||||
return parts.join(', ') + '. Nothing needs you.';
|
||||
});
|
||||
const needsYou = $derived(!!counts?.attention || !!counts?.unmatched);
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -36,10 +42,12 @@
|
||||
<a href="/admin/integrations" class="text-accent-fg hover:underline">Integrations</a>.
|
||||
</p>
|
||||
{#if verdict}
|
||||
<p class="text-sm {counts?.attention ? 'text-text-primary' : 'text-text-secondary'}" data-testid="collection-verdict">
|
||||
<p class="text-sm {needsYou ? 'text-text-primary' : 'text-text-secondary'}" data-testid="collection-verdict">
|
||||
{verdict}
|
||||
{#if counts?.attention}
|
||||
<a href="/collection/activity" class="ml-1 text-accent-fg hover:underline">See Activity</a>
|
||||
{:else if counts?.unmatched}
|
||||
<a href="/collection/unmatched" class="ml-1 text-accent-fg hover:underline">See Unmatched</a>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -12,7 +12,7 @@ vi.mock('#lib/api/wanted.js', () => ({ createWantArtistsQuery: vi.fn() }));
|
||||
import WantedLayout from './+layout.svelte';
|
||||
import { createWantArtistsQuery } from '#lib/api/wanted.js';
|
||||
|
||||
const counts: WantCounts = { artists: 7, wanted: 5, missing: 1, held: 9, following: 2, activity: 3, attention: 0, blocked: 4 };
|
||||
const counts: WantCounts = { artists: 7, wanted: 5, missing: 1, held: 9, following: 2, activity: 3, attention: 0, blocked: 4, unmatched: 3 };
|
||||
const children = createRawSnippet(() => ({ render: () => `<p data-testid="tab-body">body</p>` }));
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
@@ -25,7 +25,7 @@ function setup(c: WantCounts = counts, path = '/collection') {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
counts: c,
|
||||
library: { listed_at: null, unlisted: 0, unmatched: [] }
|
||||
library: { listed_at: null, unlisted: 0 }
|
||||
};
|
||||
vi.mocked(createWantArtistsQuery).mockReturnValue(
|
||||
mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType<typeof createWantArtistsQuery>
|
||||
@@ -45,7 +45,8 @@ describe('/collection layout', () => {
|
||||
['/collection/missing', 'Missing files 1'],
|
||||
['/collection/activity', 'Activity 3'],
|
||||
['/collection/following', 'Following 2'],
|
||||
['/collection/blocked', 'Blocked 4']
|
||||
['/collection/blocked', 'Blocked 4'],
|
||||
['/collection/unmatched', 'Unmatched 3']
|
||||
]);
|
||||
expect(screen.getByRole('link', { name: /activity/i })).toHaveAttribute('aria-current', 'page');
|
||||
expect(screen.getByRole('link', { name: /^catalogue/i })).not.toHaveAttribute('aria-current');
|
||||
@@ -53,7 +54,7 @@ describe('/collection layout', () => {
|
||||
});
|
||||
|
||||
test('when nothing needs a person the verdict says so', () => {
|
||||
setup();
|
||||
setup({ ...counts, unmatched: 0 });
|
||||
expect(screen.getByTestId('collection-verdict').textContent).toMatch(/5 albums wanted, 3 downloading\. Nothing needs you\./);
|
||||
expect(screen.queryByTestId('tab-attention')).toBeNull();
|
||||
});
|
||||
@@ -66,6 +67,14 @@ describe('/collection layout', () => {
|
||||
expect(screen.getByTestId('tab-attention').textContent).toBe('2');
|
||||
});
|
||||
|
||||
test('library albums waiting to be matched need a person, and link to Unmatched', () => {
|
||||
setup();
|
||||
expect(screen.getByTestId('collection-verdict').textContent).toMatch(
|
||||
/5 albums wanted, 3 downloading\. 3 albums in your library need matching\./
|
||||
);
|
||||
expect(screen.getByRole('link', { name: /see unmatched/i }).getAttribute('href')).toBe('/collection/unmatched');
|
||||
});
|
||||
|
||||
test("an artist's page sits under Catalogue", () => {
|
||||
setup(counts, '/collection/artist/blur');
|
||||
expect(screen.getByRole('link', { name: /^catalogue/i })).toHaveAttribute('aria-current', 'page');
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import UnmatchedList from '#lib/components/wanted/UnmatchedList.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Collection · Unmatched')}</title></svelte:head>
|
||||
|
||||
<UnmatchedList />
|
||||
Reference in New Issue
Block a user