feat: the Wanted view becomes Collection, shaped like Lidarr: an artist catalogue first, the state sections down a left rail (M502 #5577)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m54s
release / integration (push) Successful in 5m33s
release / android (push) Successful in 5m40s
release / Build signed APK (releases and dev) (push) Successful in 5m56s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped

The section opens on a catalogue of every artist on the list, A–Z with a
letter rail, each card showing how many of their albums are held. An artist
opens on their page: every album of theirs, held or not, with its status,
search, releases and tracks. Wanted, Missing files, Activity, Following and
Blocked sit down a left rail as the sections that show state; Held is gone,
the catalogue shows it. The section moves from /wanted to /collection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 21:14:33 -04:00
co-authored by Claude Opus 5.5
parent a4fbe44bd5
commit 358849de77
34 changed files with 994 additions and 356 deletions
+132 -50
View File
@@ -69,8 +69,10 @@ type wantGrabView struct {
ImportedFiles int32 `json:"imported_files"`
}
// wantCounts sizes each tab of the Wanted page.
// 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"`
Wanted int64 `json:"wanted"`
Missing int64 `json:"missing"`
Held int64 `json:"held"`
@@ -90,6 +92,34 @@ type wantsPage struct {
Counts wantCounts `json:"counts"`
// Library is the one-time listing of the library's albums (#5567).
Library libraryListingView `json:"library"`
// Artist heads one artist's page, when the list is narrowed to them.
Artist *wantArtistView `json:"artist,omitempty"`
}
// wantArtistView is one artist on the Wanted view's main page (#5572): how
// many of their albums the list holds, of how many, like Lidarr's artist
// index.
type wantArtistView struct {
ArtistMBID string `json:"artist_mbid"`
ArtistName string `json:"artist_name"`
SortName string `json:"sort_name"`
ArtistID string `json:"artist_id,omitempty"`
ImageURL string `json:"image_url"`
Albums int32 `json:"albums"`
Held int32 `json:"held"`
Wanted int32 `json:"wanted"`
Missing int32 `json:"missing"`
Downloading int32 `json:"downloading"`
Following bool `json:"following"`
}
type wantArtistsPage struct {
Items []wantArtistView `json:"items"`
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Counts wantCounts `json:"counts"`
Library libraryListingView `json:"library"`
}
// libraryListingView: whether the library's albums are all on the list
@@ -113,77 +143,129 @@ const unmatchedShown = 200
const wantsPageSize = 50
// handleListWants implements GET /api/admin/wanted?state=wanted|missing|held&q=&offset=.
// 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) {
counts, err := q.CountWants(r.Context())
if err != nil {
return wantCounts{}, libraryListingView{}, err
}
listing, err := q.GetLibraryListing(r.Context())
if err != nil {
return wantCounts{}, libraryListingView{}, err
}
lib := libraryListingView{Unlisted: listing.LibraryUnlisted, Unmatched: []unmatchedAlbumView{}}
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,
Following: counts.Following, Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked}, lib, nil
}
func offsetParam(r *http.Request) int {
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
return max(offset, 0)
}
func wantArtistViewOf(row dbq.ListWantArtistsRow) wantArtistView {
v := wantArtistView{
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName, SortName: row.SortName,
ImageURL: artistArtURL(row.ArtistMbid, row.ArtistName),
Albums: row.Albums, Held: row.Held, Wanted: row.Wanted, Missing: row.Missing,
Downloading: row.Downloading, Following: row.Following,
}
if row.LibraryArtistID.Valid {
v.ArtistID = uuidToString(row.LibraryArtistID)
}
return v
}
// handleListWantArtists implements GET /api/admin/wanted/artists?q=&offset=:
// the Wanted view's main page, every artist on the list A–Z (#5572).
func (h *handlers) handleListWantArtists(w http.ResponseWriter, r *http.Request) {
offset := offsetParam(r)
q := dbq.New(h.pool)
rows, err := q.ListWantArtists(r.Context(), dbq.ListWantArtistsParams{
Q: strings.TrimSpace(r.URL.Query().Get("q")), Skip: int32(offset), MaxRows: wantArtistsPageSize})
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: artists", apierror.Internal(err))
return
}
counts, lib, err := h.wantsHeader(r, q, offset == 0)
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: counts", apierror.Internal(err))
return
}
out := wantArtistsPage{Items: make([]wantArtistView, 0, len(rows)), Limit: wantArtistsPageSize, Offset: offset,
Counts: counts, Library: lib}
for _, row := range rows {
out.Total = row.Total
out.Items = append(out.Items, wantArtistViewOf(row))
}
writeJSON(w, http.StatusOK, out)
}
// handleListWants implements
// GET /api/admin/wanted?state=wanted|missing|held|all&artist=&q=&offset=.
// wanted is everything open but missing files. Each list runs artist by
// artist, A–Z, like the library (#5572); q narrows it to an artist or album.
// artist, A–Z, like the library (#5572); q narrows it to an artist or
// album, and artist to one artist's page, which it then heads.
func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
if state == "" {
state = "wanted"
}
if state != "wanted" && state != "missing" && state != "held" {
writeErr(w, apierror.BadRequest("bad_state", "state must be wanted, missing or held"))
if state != "wanted" && state != "missing" && state != "held" && state != "all" {
writeErr(w, apierror.BadRequest("bad_state", "state must be wanted, missing, held or all"))
return
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
offset := offsetParam(r)
artist := strings.TrimSpace(r.URL.Query().Get("artist"))
q := dbq.New(h.pool)
filter := strings.TrimSpace(r.URL.Query().Get("q"))
rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: state, Q: filter, Skip: int32(offset), MaxRows: wantsPageSize})
rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: state, ArtistMbid: artist,
Q: strings.TrimSpace(r.URL.Query().Get("q")), Skip: int32(offset), MaxRows: wantsPageSize})
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: list", apierror.Internal(err))
return
}
counts, err := q.CountWants(r.Context())
counts, lib, err := h.wantsHeader(r, q, false)
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: count", apierror.Internal(err))
writeErrWithLog(w, h.logger, "admin wanted: counts", apierror.Internal(err))
return
}
listing, err := q.GetLibraryListing(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: library listing", apierror.Internal(err))
return
}
out := wantsPage{
Library: libraryListingView{Unlisted: listing.LibraryUnlisted},
Items: make([]wantView, 0, len(rows)), Limit: wantsPageSize, Offset: offset,
Counts: wantCounts{Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held, Following: counts.Following,
Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked},
}
out.Library.Unmatched = []unmatchedAlbumView{}
if listing.LibraryListedAt.Valid {
at := formatTimestamp(listing.LibraryListedAt)
out.Library.ListedAt = &at
}
if state == "held" && offset == 0 && listing.LibraryUnlisted > 0 {
unmatched, err := q.ListUnmatchedLibraryAlbums(r.Context(), unmatchedShown)
out := wantsPage{Library: lib, Counts: counts,
Items: make([]wantView, 0, len(rows)), Limit: wantsPageSize, Offset: offset}
if artist != "" {
heads, err := q.ListWantArtists(r.Context(), dbq.ListWantArtistsParams{ArtistMbid: artist, MaxRows: 1})
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: unmatched albums", apierror.Internal(err))
writeErrWithLog(w, h.logger, "admin wanted: artist", apierror.Internal(err))
return
}
for _, u := range unmatched {
out.Library.Unmatched = append(out.Library.Unmatched, unmatchedAlbumView{
AlbumID: uuidToString(u.AlbumID), Title: u.Title, Artist: u.ArtistName, Detail: u.Detail,
})
}
}
switch state {
case "missing":
out.Total = counts.Missing
case "held":
out.Total = counts.Held
default:
out.Total = counts.Wanted
}
if filter != "" {
out.Total = 0
if len(rows) > 0 {
out.Total = rows[0].Total
if len(heads) == 0 {
writeErr(w, apierror.NotFound("artist"))
return
}
v := wantArtistViewOf(heads[0])
out.Artist = &v
}
for _, row := range rows {
out.Total = row.Total
v := wantView{
ID: uuidToString(row.ID), ReleaseGroupMBID: row.ReleaseGroupMbid, Title: row.Title,
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName,
+70
View File
@@ -24,6 +24,7 @@ func doWantedReq(t *testing.T, h *handlers, path string, user dbq.User) *httptes
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/{id}/tracks", h.handleWantTracks)
})
w := httptest.NewRecorder()
@@ -98,6 +99,75 @@ func TestListWants_ArtistsAToZThenTheirAlbumsByDate(t *testing.T) {
}
}
// The Collection's catalogue (#5572): every artist on the list A–Z, each
// with how many of their albums are held; an artist's page lists every album
// of theirs, held or not, under that artist.
func TestListWantArtists_CatalogueAndAnArtistsPage(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
q := dbq.New(h.pool)
beatles := "a0000000-0000-4000-8000-000000000002"
camel := "a0000000-0000-4000-8000-000000000001"
if _, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "The Beatles", SortName: "Beatles, The", Mbid: &beatles}); err != nil {
t.Fatal(err)
}
seedWant(t, h, "g0000000-0000-4000-8000-000000000002", beatles, "The Beatles", "Abbey Road", "1969")
held := seedWant(t, h, "g0000000-0000-4000-8000-000000000003", beatles, "The Beatles", "Please Please Me", "1963")
if _, err := h.pool.Exec(ctx, `UPDATE wants SET fulfilled_at = now() WHERE id = $1`, held.ID); err != nil {
t.Fatal(err)
}
seedWant(t, h, "g0000000-0000-4000-8000-000000000004", camel, "Camel", "Moonmadness", "1976")
if _, err := h.pool.Exec(ctx, `INSERT INTO follows (artist_mbid, artist_name, profile_id)
SELECT $1, 'Camel', id FROM release_type_profiles WHERE is_default`, camel); err != nil {
t.Fatal(err)
}
w := doWantedReq(t, h, "/api/admin/wanted/artists", admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
}
var page wantArtistsPage
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
if len(page.Items) != 2 || page.Total != 2 || page.Counts.Artists != 2 {
t.Fatalf("catalogue = %+v, total %d, artists %d", page.Items, page.Total, page.Counts.Artists)
}
b, c := page.Items[0], page.Items[1]
if b.ArtistName != "The Beatles" || b.SortName != "Beatles, The" || b.Albums != 2 || b.Held != 1 || b.Wanted != 1 ||
b.ArtistID == "" || b.Following || b.ImageURL == "" {
t.Errorf("Beatles = %+v", b)
}
if c.ArtistName != "Camel" || c.Albums != 1 || c.Held != 0 || !c.Following || c.ArtistID != "" {
t.Errorf("Camel = %+v", c)
}
// q finds an artist by an album's title.
w = doWantedReq(t, h, "/api/admin/wanted/artists?q=moon", admin)
page = wantArtistsPage{}
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.Items[0].ArtistName != "Camel" {
t.Errorf("q=moon: %+v", page.Items)
}
// The Beatles' page: both albums, held or not, headed by the artist.
w = doWantedReq(t, h, "/api/admin/wanted?state=all&artist="+beatles, admin)
var albums wantsPage
if err := json.Unmarshal(w.Body.Bytes(), &albums); err != nil {
t.Fatal(err)
}
if len(albums.Items) != 2 || albums.Items[0].Title != "Please Please Me" || albums.Items[0].HeldAt == nil ||
albums.Artist == nil || albums.Artist.Held != 1 {
t.Errorf("artist page = %+v, head %+v", albums.Items, albums.Artist)
}
if w := doWantedReq(t, h, "/api/admin/wanted?state=all&artist=nobody", admin); w.Code != http.StatusNotFound {
t.Errorf("an artist with nothing on the list: status %d, want 404", w.Code)
}
}
// fakeEditions is a MusicBrainz holding one group with two editions, the
// shorter official one the standard, or 503 when down is set.
func fakeEditions(t *testing.T, group string, down bool) *httptest.Server {
+1
View File
@@ -252,6 +252,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
// for, the artists it follows, and the release-type
// profiles that decide which of their releases count.
admin.Get("/wanted", h.handleListWants)
admin.Get("/wanted/artists", h.handleListWantArtists)
admin.Delete("/wanted/{id}", h.handleRemoveWant)
// Searching and grabbing (M502 #5521).
admin.Get("/acquisition/settings", h.handleGetSearchSettings)
+108 -9
View File
@@ -37,6 +37,7 @@ const countWants = `-- name: CountWants :one
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
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,
(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.
@@ -52,6 +53,7 @@ type CountWantsRow struct {
Wanted int64
Missing int64
Held int64
Artists int64
Following int64
Activity int64
Attention int64
@@ -65,6 +67,7 @@ func (q *Queries) CountWants(ctx context.Context) (CountWantsRow, error) {
&i.Wanted,
&i.Missing,
&i.Held,
&i.Artists,
&i.Following,
&i.Activity,
&i.Attention,
@@ -852,6 +855,98 @@ func (q *Queries) ListUnmatchedLibraryAlbums(ctx context.Context, maxRows int32)
return items, nil
}
const listWantArtists = `-- name: ListWantArtists :many
WITH a AS (
SELECT w.artist_mbid,
(array_agg(w.artist_name ORDER BY w.wanted_at DESC))[1] AS artist_name,
COUNT(*) FILTER (WHERE w.fulfilled_at IS NOT NULL)::int AS held,
COUNT(*) FILTER (WHERE w.fulfilled_at IS NULL AND w.reason <> 'missing')::int AS wanted,
COUNT(*) FILTER (WHERE w.fulfilled_at IS NULL AND w.reason = 'missing')::int AS missing,
COUNT(*)::int AS albums,
bool_or($1::text <> '' AND strpos(lower(w.title), lower($1::text)) > 0) AS title_match
FROM wants w
WHERE $4::text = '' OR w.artist_mbid = $4::text
GROUP BY w.artist_mbid
)
SELECT a.artist_mbid, a.artist_name::text AS artist_name, a.held, a.wanted, a.missing, a.albums,
COALESCE(NULLIF(la.sort_name, ''), a.artist_name)::text AS sort_name,
la.id AS library_artist_id,
(f.artist_mbid IS NOT NULL)::bool AS following,
(SELECT COUNT(*) FROM grabs g JOIN wants x ON x.id = g.want_id
WHERE x.artist_mbid = a.artist_mbid AND g.ended_at IS NULL)::int AS downloading,
COUNT(*) OVER () AS total
FROM a
LEFT JOIN LATERAL (SELECT ar.id, ar.sort_name FROM artists ar WHERE ar.mbid = a.artist_mbid LIMIT 1) la ON true
LEFT JOIN follows f ON f.artist_mbid = a.artist_mbid
WHERE $1::text = ''
OR strpos(lower(a.artist_name), lower($1::text)) > 0
OR a.title_match
ORDER BY lower(COALESCE(NULLIF(la.sort_name, ''), a.artist_name)), a.artist_mbid
LIMIT $3 OFFSET $2
`
type ListWantArtistsParams struct {
Q string
Skip int32
MaxRows int32
ArtistMbid string
}
type ListWantArtistsRow struct {
ArtistMbid string
ArtistName string
Held int32
Wanted int32
Missing int32
Albums int32
SortName string
LibraryArtistID pgtype.UUID
Following bool
Downloading int32
Total int64
}
// The Wanted view's main page (#5572): every artist on the list, A–Z like
// the library, with how many of their albums are held of how many are on
// the list. q matches the artist's name or any of their albums' titles;
// artist_mbid narrows to one artist, for their page's header.
func (q *Queries) ListWantArtists(ctx context.Context, arg ListWantArtistsParams) ([]ListWantArtistsRow, error) {
rows, err := q.db.Query(ctx, listWantArtists,
arg.Q,
arg.Skip,
arg.MaxRows,
arg.ArtistMbid,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListWantArtistsRow
for rows.Next() {
var i ListWantArtistsRow
if err := rows.Scan(
&i.ArtistMbid,
&i.ArtistName,
&i.Held,
&i.Wanted,
&i.Missing,
&i.Albums,
&i.SortName,
&i.LibraryArtistID,
&i.Following,
&i.Downloading,
&i.Total,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWants = `-- name: ListWants :many
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason,
w.album_id, w.wanted_at, w.fulfilled_at,
@@ -879,24 +974,27 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
LEFT JOIN grabs gr ON gr.id = (SELECT x.id FROM grabs x WHERE x.want_id = w.id
ORDER BY x.ended_at IS NULL DESC, x.grabbed_at DESC LIMIT 1)
WHERE CASE $1::text
WHEN 'all' THEN true
WHEN 'held' THEN w.fulfilled_at IS NOT NULL
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
END
AND ($2::text = ''
OR strpos(lower(w.artist_name), lower($2::text)) > 0
OR strpos(lower(w.title), lower($2::text)) > 0)
AND ($2::text = '' OR w.artist_mbid = $2::text)
AND ($3::text = ''
OR strpos(lower(w.artist_name), lower($3::text)) > 0
OR strpos(lower(w.title), lower($3::text)) > 0)
ORDER BY lower(COALESCE(NULLIF(la.sort_name, ''), w.artist_name)), w.artist_mbid,
NULLIF(COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, ''), '') NULLS LAST,
lower(w.title), w.id
LIMIT $4 OFFSET $3
LIMIT $5 OFFSET $4
`
type ListWantsParams struct {
State string
Q string
Skip int32
MaxRows int32
State string
ArtistMbid string
Q string
Skip int32
MaxRows int32
}
type ListWantsRow struct {
@@ -937,13 +1035,14 @@ type ListWantsRow struct {
}
// The Wanted page: open wants (all but missing ones, or only missing ones),
// or the held ones. Ordered like the library (#5572): artists A–Z by the
// the held ones, or all of them; artist_mbid narrows to one artist's page. Ordered like the library (#5572): artists A–Z by the
// library's sort name when it holds the artist, each artist's albums by
// release date, so the page groups them as they arrive. q narrows to an
// artist or album title; total counts every match, past the page.
func (q *Queries) ListWants(ctx context.Context, arg ListWantsParams) ([]ListWantsRow, error) {
rows, err := q.db.Query(ctx, listWants,
arg.State,
arg.ArtistMbid,
arg.Q,
arg.Skip,
arg.MaxRows,
+37 -1
View File
@@ -177,7 +177,7 @@ DELETE FROM wants WHERE id = $1;
-- name: ListWants :many
-- The Wanted page: open wants (all but missing ones, or only missing ones),
-- or the held ones. Ordered like the library (#5572): artists A–Z by the
-- the held ones, or all of them; artist_mbid narrows to one artist's page. Ordered like the library (#5572): artists A–Z by the
-- library's sort name when it holds the artist, each artist's albums by
-- release date, so the page groups them as they arrive. q narrows to an
-- artist or album title; total counts every match, past the page.
@@ -207,10 +207,12 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
LEFT JOIN grabs gr ON gr.id = (SELECT x.id FROM grabs x WHERE x.want_id = w.id
ORDER BY x.ended_at IS NULL DESC, x.grabbed_at DESC LIMIT 1)
WHERE CASE sqlc.arg(state)::text
WHEN 'all' THEN true
WHEN 'held' THEN w.fulfilled_at IS NOT NULL
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
END
AND (sqlc.arg(artist_mbid)::text = '' OR w.artist_mbid = sqlc.arg(artist_mbid)::text)
AND (sqlc.arg(q)::text = ''
OR strpos(lower(w.artist_name), lower(sqlc.arg(q)::text)) > 0
OR strpos(lower(w.title), lower(sqlc.arg(q)::text)) > 0)
@@ -219,6 +221,39 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
lower(w.title), w.id
LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
-- name: ListWantArtists :many
-- The Wanted view's main page (#5572): every artist on the list, A–Z like
-- the library, with how many of their albums are held of how many are on
-- the list. q matches the artist's name or any of their albums' titles;
-- artist_mbid narrows to one artist, for their page's header.
WITH a AS (
SELECT w.artist_mbid,
(array_agg(w.artist_name ORDER BY w.wanted_at DESC))[1] AS artist_name,
COUNT(*) FILTER (WHERE w.fulfilled_at IS NOT NULL)::int AS held,
COUNT(*) FILTER (WHERE w.fulfilled_at IS NULL AND w.reason <> 'missing')::int AS wanted,
COUNT(*) FILTER (WHERE w.fulfilled_at IS NULL AND w.reason = 'missing')::int AS missing,
COUNT(*)::int AS albums,
bool_or(sqlc.arg(q)::text <> '' AND strpos(lower(w.title), lower(sqlc.arg(q)::text)) > 0) AS title_match
FROM wants w
WHERE sqlc.arg(artist_mbid)::text = '' OR w.artist_mbid = sqlc.arg(artist_mbid)::text
GROUP BY w.artist_mbid
)
SELECT a.artist_mbid, a.artist_name::text AS artist_name, a.held, a.wanted, a.missing, a.albums,
COALESCE(NULLIF(la.sort_name, ''), a.artist_name)::text AS sort_name,
la.id AS library_artist_id,
(f.artist_mbid IS NOT NULL)::bool AS following,
(SELECT COUNT(*) FROM grabs g JOIN wants x ON x.id = g.want_id
WHERE x.artist_mbid = a.artist_mbid AND g.ended_at IS NULL)::int AS downloading,
COUNT(*) OVER () AS total
FROM a
LEFT JOIN LATERAL (SELECT ar.id, ar.sort_name FROM artists ar WHERE ar.mbid = a.artist_mbid LIMIT 1) la ON true
LEFT JOIN follows f ON f.artist_mbid = a.artist_mbid
WHERE sqlc.arg(q)::text = ''
OR strpos(lower(a.artist_name), lower(sqlc.arg(q)::text)) > 0
OR a.title_match
ORDER BY lower(COALESCE(NULLIF(la.sort_name, ''), a.artist_name)), a.artist_mbid
LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
-- name: GetWantAlbum :one
-- What the Wanted view's track list needs of a want (#5572).
SELECT id, release_group_mbid, album_id FROM wants WHERE id = $1;
@@ -235,6 +270,7 @@ SELECT title, disc_number, track_number, duration_ms, (missing_since IS NOT NULL
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
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,
(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.
+3 -1
View File
@@ -41,7 +41,9 @@ export const qk = {
['adminRequests', { status: status ?? 'all' }] as const,
// Wanted list (M502 #5520). Wants sit under ['adminWants'] so one prefix
// invalidation refreshes every tab and its counts.
adminWants: (state: string, q: string = '') => ['adminWants', { state, q }] as const,
adminWants: (state: string, q: string = '', artist: string = '') =>
['adminWants', { state, q, artist }] as const,
wantArtists: (q: string = '') => ['adminWants', 'artists', { q }] as const,
wantTracks: (id: string) => ['wantTracks', id] as const,
adminFollows: () => ['adminFollows'] as const,
adminFollow: (artistMbid: string) => ['adminFollow', artistMbid] as const,
+24 -2
View File
@@ -571,7 +571,8 @@ export type ResolvedDuplicatesResponse = {
// library: in the library when the list began (#5567).
export type WantReason = 'request' | 'follow' | 'missing' | 'library';
export type WantState = 'wanted' | 'missing' | 'held';
// all: one artist's page, every album of theirs on the list (#5572).
export type WantState = 'wanted' | 'missing' | 'held' | 'all';
// One release group on the wanted list. held_at is set once the library
// holds it; artist_id and album_id link into the library when it has them.
@@ -722,6 +723,8 @@ export type SearchSettings = {
};
export type WantCounts = {
// Artists in the Collection's catalogue (#5572).
artists: number;
wanted: number;
missing: number;
held: number;
@@ -819,7 +822,26 @@ export type UnmatchedAlbum = { album_id: string; title: string; artist: string;
export type LibraryListing = { listed_at: string | null; unlisted: number; unmatched: UnmatchedAlbum[] };
export type WantsPage = Page<Want> & { counts: WantCounts; library: LibraryListing };
// artist heads the list when it is narrowed to one artist's page.
export type WantsPage = Page<Want> & { counts: WantCounts; library: LibraryListing; artist?: WantArtist };
// One artist in the Collection's catalogue (#5572), like Lidarr's artist
// index: how many of their albums on the list are held, of how many.
export type WantArtist = {
artist_mbid: string;
artist_name: string;
sort_name: string;
artist_id?: string;
image_url: string;
albums: number;
held: number;
wanted: number;
missing: number;
downloading: number;
following: boolean;
};
export type WantArtistsPage = Page<WantArtist> & { counts: WantCounts; library: LibraryListing };
// An album's tracks in the Wanted view (#5572): the library's own for an
// album it holds (a lost file marked missing), else MusicBrainz's standard
+36 -5
View File
@@ -13,6 +13,7 @@ import type {
GrabResult,
SearchResult,
SearchSettings,
WantArtistsPage,
WantState,
WantTracks,
WantsPage
@@ -22,10 +23,17 @@ import type {
// artists it follows for new releases, and the release-type profiles that
// say which kinds of release a follow wants.
// q narrows the list to an artist or album whose name holds it (#5572).
export async function listWants(state: WantState, offset: number = 0, q: string = ''): Promise<WantsPage> {
// q narrows the list to an artist or album whose name holds it; artist to
// one artist's page, which the response then heads (#5572).
export async function listWants(
state: WantState,
offset: number = 0,
q: string = '',
artist: string = ''
): Promise<WantsPage> {
const params = new URLSearchParams({ state, offset: String(offset) });
if (q) params.set('q', q);
if (artist) params.set('artist', artist);
return api.get<WantsPage>(`/api/admin/wanted?${params}`);
}
@@ -35,10 +43,10 @@ export function wantsNextOffset(last: WantsPage): number | undefined {
}
// Loads as the page scrolls (rule 172), one tab at a time.
export function createWantsQuery(state: WantState, q: string = '') {
export function createWantsQuery(state: WantState, q: string = '', artist: string = '') {
return createInfiniteQuery({
queryKey: qk.adminWants(state, q),
queryFn: ({ pageParam }) => listWants(state, pageParam as number, q),
queryKey: qk.adminWants(state, q, artist),
queryFn: ({ pageParam }) => listWants(state, pageParam as number, q, artist),
initialPageParam: 0,
getNextPageParam: wantsNextOffset,
// A new filter keeps the list it narrows on screen until its answer comes.
@@ -47,6 +55,29 @@ export function createWantsQuery(state: WantState, q: string = '') {
});
}
// The Collection's catalogue (#5572): every artist on the list, A–Z, with
// how many of their albums are held. Its first page carries every section's
// count.
export async function listWantArtists(offset: number = 0, q: string = ''): Promise<WantArtistsPage> {
const params = new URLSearchParams({ offset: String(offset) });
if (q) params.set('q', q);
return api.get<WantArtistsPage>(`/api/admin/wanted/artists?${params}`);
}
export function createWantArtistsQuery(q: string = '') {
return createInfiniteQuery({
queryKey: qk.wantArtists(q),
queryFn: ({ pageParam }) => listWantArtists(pageParam as number, q),
initialPageParam: 0,
getNextPageParam: (last: WantArtistsPage) => {
const loaded = last.offset + last.items.length;
return last.items.length === 0 || loaded >= last.total ? undefined : loaded;
},
placeholderData: keepPreviousData,
staleTime: 30_000
});
}
// An album's tracks (#5572): the library's own for an album it holds,
// otherwise MusicBrainz's standard edition.
export async function getWantTracks(id: string): Promise<WantTracks> {
@@ -74,7 +74,7 @@
<h3 class="font-display text-lg font-medium text-text-primary">Automatic re-acquisition</h3>
<p class="mt-1 text-sm text-text-secondary">
When a file has been missing for a while, Minstrel can put its album back on the
<a href="/wanted/missing" class="text-accent-fg hover:underline">wanted list</a>
<a href="/collection/missing" class="text-accent-fg hover:underline">wanted list</a>
by itself. It's wanted per album, not per track: a whole folder going missing is one
want, not forty.
</p>
@@ -104,7 +104,7 @@
<h3 class="font-display text-lg font-medium text-text-primary">Searching and grabbing</h3>
<p class="mt-1 text-sm text-text-secondary">
Minstrel searches your indexers for each album on the
<a href="/wanted" class="text-accent-fg hover:underline">wanted list</a>, judges every
<a href="/collection/wanted" class="text-accent-fg hover:underline">wanted list</a>, judges every
release it finds, and hands the best one to your download client. Each album's releases and
the reason for every verdict are on the Wanted page.
</p>
+4 -3
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { ChevronDown, Compass, House, LibraryBig, ListChecks } from 'lucide-svelte';
import { ChevronDown, Compass, Disc3, House, LibraryBig } from 'lucide-svelte';
import { user, logout } from '#lib/auth/store.svelte.js';
import { player } from '#lib/player/store.svelte.js';
import { appName } from '#lib/branding.js';
@@ -52,13 +52,14 @@
// bounce through the /library 308-redirect. isActive('/library')
// still matches all sub-routes by prefix so the active state covers
// every tab.
// Wanted (M502) is an admin's: what Minstrel is fetching for the library.
// Collection (M502) is an admin's: the catalogue of what the library holds
// and wants, and what Minstrel is fetching for it, shaped like Lidarr's.
const navItems = $derived([
{ href: '/', label: 'Home', icon: House, matchPrefix: '/' },
{ href: '/library/artists', label: 'Library', icon: LibraryBig, matchPrefix: '/library' },
{ href: '/discover', label: 'Discover', icon: Compass, matchPrefix: '/discover' },
...(user.value?.is_admin
? [{ href: '/wanted', label: 'Wanted', icon: ListChecks, matchPrefix: '/wanted' }]
? [{ href: '/collection', label: 'Collection', icon: Disc3, matchPrefix: '/collection' }]
: [])
]);
</script>
+4 -4
View File
@@ -67,19 +67,19 @@ describe('Shell', () => {
expect(screen.getByRole('link', { name: 'Discover' })).toHaveAttribute('href', '/discover');
});
test('admins get Wanted after Discover; others do not', () => {
test('admins get Collection after Discover; others do not', () => {
const primary = () =>
screen
.getAllByRole('link')
.filter((el) => el.closest('nav[aria-label="Primary"]') !== null)
.map((el) => el.textContent?.trim());
const { unmount } = render(Shell);
expect(primary()).not.toContain('Wanted');
expect(primary()).not.toContain('Collection');
unmount();
userState.current = { id: '1', username: 'alice', is_admin: true };
render(Shell);
expect(primary()).toEqual(['Home', 'Library', 'Discover', 'Wanted']);
expect(screen.getByRole('link', { name: 'Wanted' })).toHaveAttribute('href', '/wanted');
expect(primary()).toEqual(['Home', 'Library', 'Discover', 'Collection']);
expect(screen.getByRole('link', { name: 'Collection' })).toHaveAttribute('href', '/collection');
});
test('main nav omits Artists/Albums/Liked/History/Playlists/Search/Settings/Admin', () => {
-54
View File
@@ -1,54 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import type { WantCounts } from '#lib/api/types.js';
// The Wanted view's sections (M502 #5520–#5522). Activity carries a second,
// accented count when downloads are waiting on a person.
let { counts }: { counts?: WantCounts } = $props();
type Item = { href: string; label: string; count?: number; attention?: number };
const items = $derived<Item[]>([
{ href: '/wanted', label: 'Wanted', count: counts?.wanted },
{ href: '/wanted/activity', label: 'Activity', count: counts?.activity, attention: counts?.attention },
{ href: '/wanted/missing', label: 'Missing files', count: counts?.missing },
{ href: '/wanted/held', label: 'Held', count: counts?.held },
{ href: '/wanted/following', label: 'Following', count: counts?.following },
{ href: '/wanted/blocked', label: 'Blocked', count: counts?.blocked }
]);
function isActive(href: string): boolean {
if (href === '/wanted') return page.url.pathname === '/wanted';
return page.url.pathname.startsWith(href);
}
</script>
<nav aria-label="Wanted sections" class="border-b border-border">
<ul class="flex gap-1 overflow-x-auto">
{#each items as item (item.href)}
{@const active = isActive(item.href)}
<li>
<a
href={item.href}
aria-current={active ? 'page' : undefined}
class="flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-sm transition-colors {active
? 'border-accent text-text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'}"
>
{item.label}
{#if item.attention}
<span
class="rounded-full bg-accent px-1.5 text-xs text-text-on-accent"
aria-label="{item.attention} need you"
data-testid="tab-attention">{item.attention}</span
>
{/if}
{#if item.count !== undefined}
<span class="text-xs text-text-muted">{item.count}</span>
{/if}
</a>
</li>
{/each}
</ul>
</nav>
@@ -0,0 +1,46 @@
<script lang="ts">
import Cover from '#lib/components/Cover.svelte';
import type { WantArtist } from '#lib/api/types.js';
// One artist in the Collection's catalogue (#5572), like a Lidarr artist
// poster: the picture, a bar of how many of their albums on the list are
// held, and whether Minstrel follows them for new releases.
let { artist: a }: { artist: WantArtist } = $props();
const pct = $derived(a.albums > 0 ? Math.round((a.held / a.albums) * 100) : 0);
const notes = $derived(
[
a.following ? 'Following' : '',
a.downloading ? `${a.downloading} downloading` : '',
a.missing ? `${a.missing} missing` : ''
].filter(Boolean)
);
</script>
<a
href="/collection/artist/{encodeURIComponent(a.artist_mbid)}"
class="group block overflow-hidden rounded-md border border-border bg-surface hover:border-accent focus-visible:ring-2 focus-visible:ring-accent"
data-testid="catalogue-artist"
>
<div class="relative aspect-square w-full overflow-hidden bg-surface-hover">
<Cover src={a.image_url} fallback="artist" class="transition-transform group-hover:scale-[1.03]" />
</div>
<div
class="h-1.5 w-full bg-surface-hover"
role="progressbar"
aria-label="{a.held} of {a.albums} albums held"
aria-valuemin={0}
aria-valuemax={a.albums}
aria-valuenow={a.held}
>
<div class="h-full bg-accent" style="width: {pct}%"></div>
</div>
<div class="space-y-0.5 px-2 py-1.5 text-center">
<div class="truncate text-sm font-medium text-text-primary">{a.artist_name}</div>
<div class="text-xs tabular-nums text-text-secondary">{a.held} / {a.albums} held</div>
{#if notes.length}
<div class="truncate text-xs text-text-muted">{notes.join(' · ')}</div>
{/if}
</div>
</a>
@@ -0,0 +1,110 @@
<script lang="ts">
import { ChevronDown, 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';
import QuickFilter from '#lib/components/QuickFilter.svelte';
import CatalogueArtistCard from './CatalogueArtistCard.svelte';
import type { WantArtist } from '#lib/api/types.js';
// The Collection's catalogue (#5572), its main page, shaped like Lidarr's
// artist index so a person coming from Lidarr finds their way: every
// 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.
let filter = $state('');
const q = $derived(filter.trim());
const artistsStore = $derived(createWantArtistsQuery(q));
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}
<p class="mb-3 text-sm text-text-secondary" data-testid="library-listing">
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)}
<div class="mb-3">
<QuickFilter bind:value={filter} placeholder="Filter artists and albums" />
</div>
{/if}
{#if artists.isPending}
<p class="text-text-secondary">Reading the catalogue…</p>
{:else if artists.isError && !artists.isFetchNextPageError}
<p class="text-error-fg">Couldn't load the catalogue.</p>
{:else if items.length === 0 && q}
<p class="text-text-secondary" data-testid="catalogue-filter-empty">
No artist in the catalogue, or album of theirs, is named like <span class="font-medium">'{q}'</span>.
</p>
{:else if items.length === 0}
<div class="rounded-lg border border-border bg-surface p-6 text-center">
<ListChecks size={28} strokeWidth={1} class="mx-auto text-text-muted" />
<p class="mt-3 text-text-primary">The catalogue is empty.</p>
<p class="mt-1 text-sm text-text-secondary">
Your library's albums land here once it's been scanned, as do the albums users request and
the releases of artists Minstrel follows.
</p>
</div>
{:else}
<AlphabeticalGrid
{items}
getKey={(a: WantArtist) => a.sort_name || a.artist_name}
hasMore={!!artists.hasNextPage}
onLoadMore={() => artists.fetchNextPage()}
>
{#snippet item(a: WantArtist)}
<CatalogueArtistCard artist={a} />
{/snippet}
</AlphabeticalGrid>
<ListContinuation
hasMore={!!artists.hasNextPage}
loading={artists.isFetchingNextPage}
failed={artists.isFetchNextPageError}
onMore={() => artists.fetchNextPage()}
/>
{/if}
@@ -0,0 +1,103 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { mockInfiniteQuery } from '../../../test-utils/query';
import type { LibraryListing, WantArtist, WantArtistsPage } from '#lib/api/types.js';
vi.mock('#lib/api/wanted.js', () => ({ createWantArtistsQuery: vi.fn() }));
import CatalogueGrid from './CatalogueGrid.svelte';
import { createWantArtistsQuery } from '#lib/api/wanted.js';
const DAY = 24 * 3_600_000;
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
function artist(mbid: string, name: string, extra: Partial<WantArtist> = {}): WantArtist {
return {
artist_mbid: mbid,
artist_name: name,
sort_name: name,
image_url: `/api/catalogue/art/artist/${mbid}`,
albums: 4,
held: 3,
wanted: 1,
missing: 0,
downloading: 0,
following: false,
...extra
};
}
function setup(
items: WantArtist[] = [
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: [] }
) {
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 },
library
};
vi.mocked(createWantArtistsQuery).mockReturnValue(
mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType<typeof createWantArtistsQuery>
);
return render(CatalogueGrid);
}
afterEach(() => vi.clearAllMocks());
describe('CatalogueGrid', () => {
test('each artist is a card: how many albums are held, and whether they are followed', () => {
setup();
const cards = screen.getAllByTestId('catalogue-artist');
expect(cards.map((c) => c.getAttribute('href'))).toEqual(['/collection/artist/blur', '/collection/artist/beatles']);
expect(cards[0].textContent?.replace(/\s+/g, ' ')).toMatch(/Blur 3 \/ 4 held Following · 1 downloading/);
const bar = screen.getByRole('progressbar', { name: '13 of 13 albums held' });
expect(bar.getAttribute('aria-valuenow')).toBe('13');
expect((bar.firstElementChild as HTMLElement).style.width).toBe('100%');
});
test('the alphabet rail files an artist under their sort name', () => {
setup();
expect(document.getElementById('alpha-B')?.textContent).toMatch(/Blur/);
});
test('the filter asks the server for artists by name or album', async () => {
setup();
await fireEvent.input(screen.getByRole('searchbox', { name: /filter artists and albums/i }), {
target: { value: 'parklife' }
});
await waitFor(() => expect(createWantArtistsQuery).toHaveBeenLastCalledWith('parklife'));
});
test('an empty catalogue says what lands there', () => {
setup([]);
expect(screen.getByText(/the catalogue is empty/i)).toBeTruthy();
});
test('says while the library is being listed', () => {
setup([], { listed_at: null, unlisted: 0, unmatched: [] });
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();
});
});
@@ -0,0 +1,58 @@
<script lang="ts">
import { page } from '$app/state';
import type { WantCounts } from '#lib/api/types.js';
// 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.
let { counts }: { counts?: WantCounts } = $props();
type Item = { href: string; label: string; count?: number; attention?: number };
const items = $derived<Item[]>([
{ href: '/collection', label: 'Catalogue', count: counts?.artists },
{ href: '/collection/wanted', label: 'Wanted', count: counts?.wanted },
{ 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 }
]);
// An artist's page belongs to the catalogue.
function isActive(href: string): boolean {
const path = page.url.pathname;
if (href === '/collection') return path === '/collection' || path.startsWith('/collection/artist/');
return path.startsWith(href);
}
</script>
<nav aria-label="Collection sections" class="border-b border-border md:border-b-0 md:border-r md:pr-3">
<ul class="flex gap-1 overflow-x-auto md:flex-col md:overflow-visible">
{#each items as item (item.href)}
{@const active = isActive(item.href)}
<li>
<a
href={item.href}
aria-current={active ? 'page' : undefined}
class="flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-sm transition-colors md:rounded-md md:border-b-0 md:border-l-2 {active
? 'border-accent text-text-primary md:bg-surface-hover'
: 'border-transparent text-text-secondary hover:text-text-primary md:hover:bg-surface-hover'}"
>
<span class="md:flex-1">{item.label}</span>
{#if item.attention}
<span
class="rounded-full bg-accent px-1.5 text-xs text-text-on-accent"
aria-label="{item.attention} need you"
data-testid="tab-attention">{item.attention}</span
>
{/if}
{#if item.count !== undefined}
<span class="text-xs tabular-nums text-text-muted">{item.count}</span>
{/if}
</a>
</li>
{/each}
</ul>
</nav>
+42 -64
View File
@@ -7,50 +7,51 @@
import { pushToast } from '#lib/stores/toast.svelte.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import ListContinuation from '#lib/components/ListContinuation.svelte';
import Cover from '#lib/components/Cover.svelte';
import FollowControl from '#lib/components/FollowControl.svelte';
import QuickFilter from '#lib/components/QuickFilter.svelte';
import WantCandidates from '#lib/components/WantCandidates.svelte';
import WantTracks from './WantTracks.svelte';
import type { SearchResult, Want, WantState } from '#lib/api/types.js';
// One tab of the Wanted view (M502 #5520–#5522): the albums still wanted,
// the missing albums wanted back, or the ones held. Each open want says
// what its last search found and how its download is getting on; Releases
// lists every candidate with its verdict, and Search runs one now. The
// library's albums were put on the list as held once (#5567), each matched to
// MusicBrainz (#5569); taking one off leaves its files and stops it being
// fetched back if they go. The ones no match was found for are named here.
// Albums on the Collection's list (M502 #5520–#5522): the ones still
// wanted, the missing ones wanted back, or, given an artist, every album
// of theirs in the catalogue, held or not, under a header (#5572). Each
// open want says what its last search found and how its download is
// getting on; Releases lists every candidate with its verdict, and Search
// runs one now. Taking a held album off leaves its files and stops it
// being fetched back if they go.
//
// The list reads like the library (#5572): artists A–Z, each with its
// albums oldest first, and each album's tracks a click away. The filter
// narrows it to an artist or album by name, on the server, so it finds
// what has not been scrolled to yet.
let { list }: { list: WantState } = $props();
let { list = 'wanted', artist = '' }: { list?: WantState; artist?: string } = $props();
const client = useQueryClient();
let filter = $state('');
const q = $derived(filter.trim());
const wantsStore = $derived(createWantsQuery(list, q));
const wantsStore = $derived(artist ? createWantsQuery('all', '', artist) : createWantsQuery(list, q));
const wants = $derived($wantsStore);
const items = $derived((wants.data?.pages ?? []).flatMap((p) => p.items) as Want[]);
const library = $derived(wants.data?.pages[0]?.library);
const head = $derived(wants.data?.pages[0]?.artist);
// The server sends the list artist by artist; each run of one artist's
// albums is a section.
type ArtistSection = { key: string; name: string; artistId?: string; wants: Want[] };
type ArtistSection = { key: string; name: string; mbid: string; wants: Want[] };
const sections = $derived.by(() => {
const out: ArtistSection[] = [];
for (const w of items) {
const key = w.artist_mbid || w.artist_name;
const last = out[out.length - 1];
if (last && last.key === key) last.wants.push(w);
else out.push({ key, name: w.artist_name, artistId: w.artist_id, wants: [w] });
else out.push({ key, name: w.artist_name, mbid: w.artist_mbid, wants: [w] });
}
return out;
});
let busy = $state<string | null>(null);
let showUnmatched = $state(false);
let searching = $state<string | null>(null);
let open = $state<string | null>(null);
let shown = $state<string | null>(null);
@@ -187,53 +188,30 @@
}
</script>
{#if list === 'held' && library}
{#if !library.listed_at}
<p class="mb-3 text-sm text-text-secondary" data-testid="library-listing">
Your library's albums are being added to this list as held. 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.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"} on this list: 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}
{#if artist && head}
<header class="mb-4 flex items-center gap-4" data-testid="catalogue-artist-head">
<div class="relative h-24 w-24 shrink-0 overflow-hidden rounded-md bg-surface-hover">
<Cover src={head.image_url} fallback="artist" />
</div>
{/if}
<div class="min-w-0 space-y-1">
<a href="/collection" class="text-xs text-text-secondary hover:text-text-primary">← Catalogue</a>
<h2 class="truncate text-xl font-medium text-text-primary">{head.artist_name}</h2>
<p class="text-sm text-text-secondary" data-testid="catalogue-artist-counts">
{head.held} of {albums(head.albums)} held{head.wanted ? ` · ${head.wanted} wanted` : ''}{head.missing
? ` · ${head.missing} with files missing`
: ''}{head.downloading ? ` · ${head.downloading} downloading` : ''}
</p>
<div class="flex flex-wrap items-center gap-3 text-sm">
<FollowControl artistMbid={head.artist_mbid} artistName={head.artist_name} />
{#if head.artist_id}
<a href="/artists/{head.artist_id}" class="text-accent-fg hover:underline">Open in the library</a>
{/if}
</div>
</div>
</header>
{/if}
{#if !wants.isPending && (items.length > 0 || q)}
{#if !artist && !wants.isPending && (items.length > 0 || q)}
<div class="mb-3">
<QuickFilter bind:value={filter} placeholder="Filter artists and albums" />
</div>
@@ -242,7 +220,7 @@
{#if wants.isPending}
<p class="text-text-secondary">Reading the wanted list…</p>
{:else if wants.isError && !wants.isFetchNextPageError}
<p class="text-error-fg">Couldn't load the wanted list.</p>
<p class="text-error-fg">Couldn't load {artist ? "this artist's albums" : 'the wanted list'}.</p>
{:else if items.length === 0 && q}
<p class="text-text-secondary" data-testid="want-filter-empty">
Nothing on this list has an artist or album named like <span class="font-medium">'{q}'</span>.
@@ -251,7 +229,7 @@
<div class="rounded-lg border border-border bg-surface p-6 text-center">
<ListChecks size={28} strokeWidth={1} class="mx-auto text-text-muted" />
<p class="mt-3 text-text-primary">
{#if list === 'held'}Nothing on the list has been held yet.
{#if artist}None of this artist's albums are in the catalogue.
{:else if list === 'missing'}No missing albums are wanted back.
{:else}Nothing is wanted.{/if}
</p>
@@ -268,16 +246,16 @@
<div class="space-y-4">
{#each sections as a, i (a.key + ':' + i)}
<section data-testid="want-artist" aria-label={a.name}>
<h2 class="mb-1.5 flex items-baseline gap-2 px-1">
{#if !artist}<h2 class="mb-1.5 flex items-baseline gap-2 px-1">
<span class="truncate text-sm font-medium text-text-primary">
{#if a.artistId}
<a href="/artists/{a.artistId}" class="hover:underline">{a.name}</a>
{#if a.mbid}
<a href="/collection/artist/{encodeURIComponent(a.mbid)}" class="hover:underline">{a.name}</a>
{:else}
{a.name}
{/if}
</span>
<span class="shrink-0 text-xs text-text-muted">{albums(a.wants.length)}</span>
</h2>
</h2>{/if}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each a.wants as w (w.id)}
<li class="space-y-2 p-3" data-testid="want-row">
@@ -307,7 +285,7 @@
>
{status(w)}
{#if attention(w)}
<a href="/wanted/activity" class="ml-1 text-accent-fg hover:underline">Decide on Activity</a>
<a href="/collection/activity" class="ml-1 text-accent-fg hover:underline">Decide on Activity</a>
{/if}
</div>
{:else if arrived(w)}
+97 -74
View File
@@ -3,18 +3,26 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { mockInfiniteQuery, mockQuery } from '../../../test-utils/query';
import type { LibraryListing, Want, WantGrab, WantsPage } from '#lib/api/types.js';
vi.mock('#lib/api/wanted.js', () => ({
createWantsQuery: vi.fn(),
removeWant: vi.fn().mockResolvedValue(undefined),
searchWant: vi.fn().mockResolvedValue({
outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', narrowed: []
}),
createCandidatesQuery: vi.fn(),
createWantTracksQuery: vi.fn(),
grabCandidate: vi.fn(),
blockCandidate: vi.fn(),
unblockRelease: vi.fn()
}));
vi.mock('#lib/api/wanted.js', async () => {
const { mockQuery } = await import('../../../test-utils/query');
return {
// The artist page's FollowControl.
createFollowQuery: vi.fn(() => mockQuery({ data: { following: true } })),
createReleaseProfilesQuery: vi.fn(() => mockQuery({ data: { profiles: [], primary_types: [], secondary_types: [] } })),
putFollow: vi.fn(),
deleteFollow: vi.fn(),
createWantsQuery: vi.fn(),
removeWant: vi.fn().mockResolvedValue(undefined),
searchWant: vi.fn().mockResolvedValue({
outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', narrowed: []
}),
createCandidatesQuery: vi.fn(),
createWantTracksQuery: vi.fn(),
grabCandidate: vi.fn(),
blockCandidate: vi.fn(),
unblockRelease: vi.fn()
};
});
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import WantList from './WantList.svelte';
@@ -27,53 +35,55 @@ const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
function want(id: string, extra: Partial<Want> = {}): Want {
return {
id,
release_group_mbid: `rg-${id}`,
title: 'The Ballad of Darren',
artist_mbid: 'blur',
artist_name: 'Blur',
artist_id: 'art-1',
primary_type: 'Album',
secondary_types: [],
year: '2023',
reason: 'follow',
wanted_at: ago(2 * DAY),
held_at: null,
image_url: '',
...extra
id,
release_group_mbid: `rg-${id}`,
title: 'The Ballad of Darren',
artist_mbid: 'blur',
artist_name: 'Blur',
artist_id: 'art-1',
primary_type: 'Album',
secondary_types: [],
year: '2023',
reason: 'follow',
wanted_at: ago(2 * DAY),
held_at: null,
image_url: '',
...extra
};
}
function page(
items: Want[],
library: LibraryListing = { listed_at: ago(DAY), unlisted: 0, unmatched: [] }
library: LibraryListing = { listed_at: ago(DAY), unlisted: 0, unmatched: [] },
artist?: WantsPage['artist']
): WantsPage {
return {
items,
total: items.length,
limit: 50,
offset: 0,
counts: { wanted: 2, missing: 1, held: 4, following: 1, activity: 1, attention: 0, blocked: 0 },
library
artist,
items,
total: items.length,
limit: 50,
offset: 0,
counts: { artists: 2, wanted: 2, missing: 1, held: 4, following: 1, activity: 1, attention: 0, blocked: 0 },
library
};
}
function grab(extra: Partial<WantGrab> = {}): WantGrab {
return {
id: 'g1',
name: 'Blur - Blur (1997) [FLAC]',
grabbed_at: ago(60_000),
origin: 'schedule',
quality: 'lossless',
live: true,
end_reason: '',
progress: 0,
import_state: '',
import_reason: '',
end_detail: '',
imported_at: null,
imported_files: 0,
...extra
id: 'g1',
name: 'Blur - Blur (1997) [FLAC]',
grabbed_at: ago(60_000),
origin: 'schedule',
quality: 'lossless',
live: true,
end_reason: '',
progress: 0,
import_state: '',
import_reason: '',
end_detail: '',
imported_at: null,
imported_files: 0,
...extra
};
}
@@ -133,29 +143,6 @@ describe('WantList', () => {
await waitFor(() => expect(removeWant).toHaveBeenCalledWith('w1'));
});
test('the held tab says while the library is being listed', () => {
setup([], 'held', { listed_at: null, unlisted: 0, unmatched: [] });
expect(screen.getByTestId('library-listing').textContent).toMatch(/being added to this list/i);
});
test('the held tab names the albums no MusicBrainz album matched, on request', async () => {
setup([want('w1', { held_at: ago(DAY) })], 'held', {
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 on this list/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(list.textContent).toMatch(/has no album called/);
expect(screen.getByRole('link', { name: 'Demo Tape' }).getAttribute('href')).toBe('/albums/al-9');
expect(screen.getByText(/the first 1 of 12/i)).toBeTruthy();
});
test('an empty list explains what lands there', () => {
setup([]);
expect(screen.getByText(/nothing is wanted/i)).toBeTruthy();
@@ -184,7 +171,7 @@ describe('WantList', () => {
]);
const line = screen.getByTestId('want-search');
expect(line.textContent).toMatch(/needs you: only the lengths agree/i);
expect(screen.getByRole('link', { name: /decide on activity/i }).getAttribute('href')).toBe('/wanted/activity');
expect(screen.getByRole('link', { name: /decide on activity/i }).getAttribute('href')).toBe('/collection/activity');
// A live download can't be searched for again.
expect((screen.getByRole('button', { name: /search for the ballad of darren now/i }) as HTMLButtonElement).disabled).toBe(true);
});
@@ -251,9 +238,9 @@ describe('WantList', () => {
expect(sections[0].querySelectorAll('[data-testid="want-row"]').length).toBe(2);
expect(sections[0].textContent).toMatch(/2 albums/);
expect(sections[1].textContent).toMatch(/1 album/);
// The artist links into the library when it holds them.
expect(screen.getByRole('link', { name: 'Blur' }).getAttribute('href')).toBe('/artists/art-1');
expect(screen.queryByRole('link', { name: 'Camel' })).toBeNull();
// Each artist opens on their page in the catalogue.
expect(screen.getByRole('link', { name: 'Blur' }).getAttribute('href')).toBe('/collection/artist/blur');
expect(screen.getByRole('link', { name: 'Camel' }).getAttribute('href')).toBe('/collection/artist/camel');
});
test('Tracks shows the album\'s tracks, with the edition they come from', async () => {
@@ -291,4 +278,40 @@ describe('WantList', () => {
});
await waitFor(() => expect(createWantsQuery).toHaveBeenLastCalledWith('wanted', 'park'));
});
test("an artist's page lists every album of theirs under a header", () => {
vi.mocked(createWantsQuery).mockReturnValue(
mockInfiniteQuery({
pages: [
page([want('w1', { held_at: ago(DAY), album_id: 'al-1', title: 'Parklife' }), want('w2')], undefined, {
artist_mbid: 'blur',
artist_name: 'Blur',
sort_name: 'Blur',
artist_id: 'art-1',
image_url: '/art/blur',
albums: 2,
held: 1,
wanted: 1,
missing: 0,
downloading: 0,
following: true
})
]
}) as unknown as ReturnType<typeof createWantsQuery>
);
vi.mocked(createCandidatesQuery).mockReturnValue(
mockQuery({ data: [] }) as unknown as ReturnType<typeof createCandidatesQuery>
);
render(WantList, { artist: 'blur' });
expect(createWantsQuery).toHaveBeenCalledWith('all', '', 'blur');
const head = screen.getByTestId('catalogue-artist-head');
expect(head.textContent).toMatch(/Blur/);
expect(screen.getByTestId('catalogue-artist-counts').textContent).toMatch(/1 of 2 albums held\s+·\s+1 wanted/);
expect(screen.getByRole('link', { name: /open in the library/i }).getAttribute('href')).toBe('/artists/art-1');
expect(screen.getByRole('link', { name: /catalogue/i }).getAttribute('href')).toBe('/collection');
// One artist: no filter, no artist headings, both albums.
expect(screen.queryByRole('searchbox')).toBeNull();
expect(screen.getAllByTestId('want-row')).toHaveLength(2);
expect(screen.queryByRole('link', { name: 'Blur' })).toBeNull();
});
});
@@ -107,7 +107,7 @@
{#if r.held_at}
Held again {relativeTime(r.held_at)}. The rows here clear on the next scan.
{:else if r.wanted_at}
On the <a href="/wanted/missing" class="text-accent-fg hover:underline"
On the <a href="/collection/missing" class="text-accent-fg hover:underline"
>wanted list</a
> since {relativeTime(r.wanted_at)}.
{/if}
@@ -140,7 +140,7 @@ describe('admin missing files', () => {
expect(
within(screen.getByTestId('reacquisition-state')).getByRole('link', { name: /wanted list/i }).getAttribute('href')
).toBe(
'/wanted/missing'
'/collection/missing'
);
});
+1 -1
View File
@@ -146,7 +146,7 @@
<h2 class="font-display text-2xl font-medium text-text-primary">Requests</h2>
<p class="text-text-secondary">
Approve or set aside what users have asked for. An approved album or track goes on the
<a href="/wanted" class="text-accent-fg hover:underline">wanted list</a>; an approved
<a href="/collection/wanted" class="text-accent-fg hover:underline">wanted list</a>; an approved
artist is followed, wanting what they've released and what they release next.
</p>
</header>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import { createWantArtistsQuery } from '#lib/api/wanted.js';
import CollectionRail from '#lib/components/wanted/CollectionRail.svelte';
let { children } = $props<{ children: import('svelte').Snippet }>();
// The Collection (M502, #5572): what the library holds and wants, and what
// Minstrel is fetching for it. Web-only and admin-only. Shaped like Lidarr
// so a person moving from it finds their way: the catalogue of artists is
// the main section, and the sections down the left show state. The
// catalogue's first page carries every section's count; the Catalogue
// shares it, so this costs no extra request there.
const artistsStore = createWantArtistsQuery();
const counts = $derived($artistsStore.data?.pages?.[0]?.counts);
// The one line that answers "does anything need me?"
const verdict = $derived.by(() => {
if (!counts) return '';
if (counts.attention) {
return counts.attention === 1 ? '1 download needs you.' : `${counts.attention} downloads need you.`;
}
const parts = [counts.wanted === 1 ? '1 album wanted' : `${counts.wanted} albums wanted`];
if (counts.activity) parts.push(`${counts.activity} downloading`);
return parts.join(', ') + '. Nothing needs you.';
});
</script>
<div class="space-y-4">
<header class="space-y-1">
<h1 class="font-display text-2xl font-medium text-text-primary">Collection</h1>
<p class="text-sm text-text-secondary">
Every artist and album in your collection: what the library holds, and what Minstrel is
getting for it, from user requests, new releases of the artists it follows, and albums whose
files went missing. It searches your indexers on a schedule, checks each download against
MusicBrainz, and files what's right into the library. Searching and quality are set under
<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">
{verdict}
{#if counts?.attention}
<a href="/collection/activity" class="ml-1 text-accent-fg hover:underline">See Activity</a>
{/if}
</p>
{/if}
</header>
<div class="md:grid md:grid-cols-[11rem_minmax(0,1fr)] md:gap-6">
<div class="mb-4 md:mb-0">
<div class="md:sticky md:top-4">
<CollectionRail {counts} />
</div>
</div>
<div class="min-w-0">
{@render children?.()}
</div>
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import CatalogueGrid from '#lib/components/wanted/CatalogueGrid.svelte';
</script>
<svelte:head><title>{pageTitle('Collection')}</title></svelte:head>
<CatalogueGrid />
@@ -3,6 +3,6 @@
import DownloadList from '#lib/components/wanted/DownloadList.svelte';
</script>
<svelte:head><title>{pageTitle('Wanted · Activity')}</title></svelte:head>
<svelte:head><title>{pageTitle('Collection · Activity')}</title></svelte:head>
<DownloadList />
@@ -0,0 +1,15 @@
<script lang="ts">
import { page } from '$app/state';
import { pageTitle } from '#lib/branding.js';
import WantList from '#lib/components/wanted/WantList.svelte';
// One artist in the Collection's catalogue (#5572): every album of theirs
// on the list, held or not, with its tracks a click away.
const mbid = $derived(page.params.mbid ?? '');
</script>
<svelte:head><title>{pageTitle('Collection · Artist')}</title></svelte:head>
{#key mbid}
<WantList artist={mbid} />
{/key}
@@ -3,6 +3,6 @@
import BlockList from '#lib/components/wanted/BlockList.svelte';
</script>
<svelte:head><title>{pageTitle('Wanted · Blocked')}</title></svelte:head>
<svelte:head><title>{pageTitle('Collection · Blocked')}</title></svelte:head>
<BlockList />
@@ -3,23 +3,23 @@ import { render, screen } from '@testing-library/svelte';
import { createRawSnippet } from 'svelte';
import { mockInfiniteQuery } from '../../test-utils/query';
import { pageUrlModule } from '#test-utils/mocks/appState.js';
import type { WantCounts, WantsPage } from '#lib/api/types.js';
import type { WantArtistsPage, WantCounts } from '#lib/api/types.js';
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/wanted') }));
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/collection') }));
vi.mock('$app/state', () => pageUrlModule(state));
vi.mock('#lib/api/wanted.js', () => ({ createWantsQuery: vi.fn() }));
vi.mock('#lib/api/wanted.js', () => ({ createWantArtistsQuery: vi.fn() }));
import WantedLayout from './+layout.svelte';
import { createWantsQuery } from '#lib/api/wanted.js';
import { createWantArtistsQuery } from '#lib/api/wanted.js';
const counts: WantCounts = { 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 };
const children = createRawSnippet(() => ({ render: () => `<p data-testid="tab-body">body</p>` }));
afterEach(() => vi.clearAllMocks());
function setup(c: WantCounts = counts, path = '/wanted') {
function setup(c: WantCounts = counts, path = '/collection') {
state.pageUrl = new URL(`http://localhost${path}`);
const page: WantsPage = {
const page: WantArtistsPage = {
items: [],
total: 0,
limit: 50,
@@ -27,42 +27,47 @@ function setup(c: WantCounts = counts, path = '/wanted') {
counts: c,
library: { listed_at: null, unlisted: 0, unmatched: [] }
};
vi.mocked(createWantsQuery).mockReturnValue(
mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType<typeof createWantsQuery>
vi.mocked(createWantArtistsQuery).mockReturnValue(
mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType<typeof createWantArtistsQuery>
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return render(WantedLayout, { children } as any);
}
describe('/wanted layout', () => {
test('tabs are links carrying their counts, the current one marked', () => {
setup(counts, '/wanted/activity');
const nav = screen.getByRole('navigation', { name: /wanted sections/i });
describe('/collection layout', () => {
test('the sections down the left carry their counts, the current one marked', () => {
setup(counts, '/collection/activity');
const nav = screen.getByRole('navigation', { name: /collection sections/i });
const links = Array.from(nav.querySelectorAll('a')).map((a) => [a.getAttribute('href'), a.textContent?.replace(/\s+/g, ' ').trim()]);
expect(links).toEqual([
['/wanted', 'Wanted 5'],
['/wanted/activity', 'Activity 3'],
['/wanted/missing', 'Missing files 1'],
['/wanted/held', 'Held 9'],
['/wanted/following', 'Following 2'],
['/wanted/blocked', 'Blocked 4']
['/collection', 'Catalogue 7'],
['/collection/wanted', 'Wanted 5'],
['/collection/missing', 'Missing files 1'],
['/collection/activity', 'Activity 3'],
['/collection/following', 'Following 2'],
['/collection/blocked', 'Blocked 4']
]);
expect(screen.getByRole('link', { name: /activity/i })).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('link', { name: /^wanted/i })).not.toHaveAttribute('aria-current');
expect(screen.getByRole('link', { name: /^catalogue/i })).not.toHaveAttribute('aria-current');
expect(screen.getByTestId('tab-body')).toBeTruthy();
});
test('when nothing needs a person the verdict says so', () => {
setup();
expect(screen.getByTestId('wanted-verdict').textContent).toMatch(/5 albums wanted, 3 downloading\. Nothing needs you\./);
expect(screen.getByTestId('collection-verdict').textContent).toMatch(/5 albums wanted, 3 downloading\. Nothing needs you\./);
expect(screen.queryByTestId('tab-attention')).toBeNull();
});
test('downloads waiting on a person lead the page and mark Activity', () => {
setup({ ...counts, attention: 2 });
const verdict = screen.getByTestId('wanted-verdict');
const verdict = screen.getByTestId('collection-verdict');
expect(verdict.textContent).toMatch(/2 downloads need you/);
expect(screen.getByRole('link', { name: /see activity/i }).getAttribute('href')).toBe('/wanted/activity');
expect(screen.getByRole('link', { name: /see activity/i }).getAttribute('href')).toBe('/collection/activity');
expect(screen.getByTestId('tab-attention').textContent).toBe('2');
});
test("an artist's page sits under Catalogue", () => {
setup(counts, '/collection/artist/blur');
expect(screen.getByRole('link', { name: /^catalogue/i })).toHaveAttribute('aria-current', 'page');
});
});
@@ -4,7 +4,7 @@
import ReleaseProfilesCard from '#lib/components/ReleaseProfilesCard.svelte';
</script>
<svelte:head><title>{pageTitle('Wanted · Following')}</title></svelte:head>
<svelte:head><title>{pageTitle('Collection · Following')}</title></svelte:head>
<div class="space-y-6">
<FollowList />
@@ -3,6 +3,6 @@
import WantList from '#lib/components/wanted/WantList.svelte';
</script>
<svelte:head><title>{pageTitle('Wanted · Missing files')}</title></svelte:head>
<svelte:head><title>{pageTitle('Collection · Missing files')}</title></svelte:head>
<WantList list="missing" />
@@ -3,6 +3,6 @@
import WantList from '#lib/components/wanted/WantList.svelte';
</script>
<svelte:head><title>{pageTitle('Wanted')}</title></svelte:head>
<svelte:head><title>{pageTitle('Collection · Wanted')}</title></svelte:head>
<WantList list="wanted" />
-48
View File
@@ -1,48 +0,0 @@
<script lang="ts">
import { createWantsQuery } from '#lib/api/wanted.js';
import WantedTabs from '#lib/components/WantedTabs.svelte';
let { children } = $props<{ children: import('svelte').Snippet }>();
// The Wanted view (M502): what Minstrel is getting for the library, its
// downloads, and what it has set aside. Web-only and admin-only. The
// wanted query's first page carries every tab's count; the Wanted tab
// shares it, so this costs no extra request there.
const wantsStore = createWantsQuery('wanted');
const counts = $derived($wantsStore.data?.pages?.[0]?.counts);
// The one line that answers "does anything need me?"
const verdict = $derived.by(() => {
if (!counts) return '';
if (counts.attention) {
return counts.attention === 1 ? '1 download needs you.' : `${counts.attention} downloads need you.`;
}
const parts = [counts.wanted === 1 ? '1 album wanted' : `${counts.wanted} albums wanted`];
if (counts.activity) parts.push(`${counts.activity} downloading`);
return parts.join(', ') + '. Nothing needs you.';
});
</script>
<div class="space-y-4">
<header class="space-y-1">
<h1 class="font-display text-2xl font-medium text-text-primary">Wanted</h1>
<p class="text-sm text-text-secondary">
Albums Minstrel is getting: ones users requested, new releases from the artists it follows,
and albums whose files went missing. It searches your indexers on a schedule, checks each
download against MusicBrainz, and files what's right into the library. Searching and quality
are set under <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="wanted-verdict">
{verdict}
{#if counts?.attention}
<a href="/wanted/activity" class="ml-1 text-accent-fg hover:underline">See Activity</a>
{/if}
</p>
{/if}
</header>
<WantedTabs {counts} />
{@render children?.()}
</div>
-8
View File
@@ -1,8 +0,0 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import WantList from '#lib/components/wanted/WantList.svelte';
</script>
<svelte:head><title>{pageTitle('Wanted · Held')}</title></svelte:head>
<WantList list="held" />