From 358849de77f7763309541d81c7f9b31ca0dd34ad Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 10 Oct 2026 21:14:33 -0400 Subject: [PATCH] feat: the Wanted view becomes Collection, shaped like Lidarr: an artist catalogue first, the state sections down a left rail (M502 #5577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/api/admin_wanted.go | 182 +++++++++++++----- internal/api/admin_wanted_test.go | 70 +++++++ internal/api/api.go | 1 + internal/db/dbq/wanted.sql.go | 117 ++++++++++- internal/db/queries/wanted.sql | 38 +++- web/src/lib/api/queries.ts | 4 +- web/src/lib/api/types.ts | 26 ++- web/src/lib/api/wanted.ts | 41 +++- .../ReacquisitionSettingsCard.svelte | 2 +- .../lib/components/SearchSettingsCard.svelte | 2 +- web/src/lib/components/Shell.svelte | 7 +- web/src/lib/components/Shell.test.ts | 8 +- web/src/lib/components/WantedTabs.svelte | 54 ------ .../wanted/CatalogueArtistCard.svelte | 46 +++++ .../components/wanted/CatalogueGrid.svelte | 110 +++++++++++ .../components/wanted/CatalogueGrid.test.ts | 103 ++++++++++ .../components/wanted/CollectionRail.svelte | 58 ++++++ web/src/lib/components/wanted/WantList.svelte | 106 ++++------ .../lib/components/wanted/WantList.test.ts | 171 +++++++++------- .../routes/admin/missing-files/+page.svelte | 2 +- .../admin/missing-files/missing-files.test.ts | 2 +- web/src/routes/admin/requests/+page.svelte | 2 +- web/src/routes/collection/+layout.svelte | 58 ++++++ .../routes/{wanted => collection}/+layout.ts | 0 web/src/routes/collection/+page.svelte | 8 + .../activity/+page.svelte | 2 +- .../collection/artist/[mbid]/+page.svelte | 15 ++ .../blocked/+page.svelte | 2 +- .../collection.test.ts} | 51 ++--- .../following/+page.svelte | 2 +- .../missing/+page.svelte | 2 +- .../{ => collection}/wanted/+page.svelte | 2 +- web/src/routes/wanted/+layout.svelte | 48 ----- web/src/routes/wanted/held/+page.svelte | 8 - 34 files changed, 994 insertions(+), 356 deletions(-) delete mode 100644 web/src/lib/components/WantedTabs.svelte create mode 100644 web/src/lib/components/wanted/CatalogueArtistCard.svelte create mode 100644 web/src/lib/components/wanted/CatalogueGrid.svelte create mode 100644 web/src/lib/components/wanted/CatalogueGrid.test.ts create mode 100644 web/src/lib/components/wanted/CollectionRail.svelte create mode 100644 web/src/routes/collection/+layout.svelte rename web/src/routes/{wanted => collection}/+layout.ts (100%) create mode 100644 web/src/routes/collection/+page.svelte rename web/src/routes/{wanted => collection}/activity/+page.svelte (67%) create mode 100644 web/src/routes/collection/artist/[mbid]/+page.svelte rename web/src/routes/{wanted => collection}/blocked/+page.svelte (66%) rename web/src/routes/{wanted/wanted.test.ts => collection/collection.test.ts} (52%) rename web/src/routes/{wanted => collection}/following/+page.svelte (78%) rename web/src/routes/{wanted => collection}/missing/+page.svelte (66%) rename web/src/routes/{ => collection}/wanted/+page.svelte (68%) delete mode 100644 web/src/routes/wanted/+layout.svelte delete mode 100644 web/src/routes/wanted/held/+page.svelte diff --git a/internal/api/admin_wanted.go b/internal/api/admin_wanted.go index 852d9cda..d58a20b1 100644 --- a/internal/api/admin_wanted.go +++ b/internal/api/admin_wanted.go @@ -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, diff --git a/internal/api/admin_wanted_test.go b/internal/api/admin_wanted_test.go index db7f9baa..ecfc70da 100644 --- a/internal/api/admin_wanted_test.go +++ b/internal/api/admin_wanted_test.go @@ -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 { diff --git a/internal/api/api.go b/internal/api/api.go index 68825e18..b8d7d27a 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) diff --git a/internal/db/dbq/wanted.sql.go b/internal/db/dbq/wanted.sql.go index c33da297..22469f12 100644 --- a/internal/db/dbq/wanted.sql.go +++ b/internal/db/dbq/wanted.sql.go @@ -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, diff --git a/internal/db/queries/wanted.sql b/internal/db/queries/wanted.sql index 173e582c..7aa34649 100644 --- a/internal/db/queries/wanted.sql +++ b/internal/db/queries/wanted.sql @@ -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. diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index b70e78f4..4dbde645 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -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, diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 9e3950ff..3684ccb3 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -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 & { counts: WantCounts; library: LibraryListing }; +// artist heads the list when it is narrowed to one artist's page. +export type WantsPage = Page & { 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 & { 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 diff --git a/web/src/lib/api/wanted.ts b/web/src/lib/api/wanted.ts index c81f6f18..d5a60f9f 100644 --- a/web/src/lib/api/wanted.ts +++ b/web/src/lib/api/wanted.ts @@ -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 { +// 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 { const params = new URLSearchParams({ state, offset: String(offset) }); if (q) params.set('q', q); + if (artist) params.set('artist', artist); return api.get(`/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 { + const params = new URLSearchParams({ offset: String(offset) }); + if (q) params.set('q', q); + return api.get(`/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 { diff --git a/web/src/lib/components/ReacquisitionSettingsCard.svelte b/web/src/lib/components/ReacquisitionSettingsCard.svelte index 490b7664..437faeca 100644 --- a/web/src/lib/components/ReacquisitionSettingsCard.svelte +++ b/web/src/lib/components/ReacquisitionSettingsCard.svelte @@ -74,7 +74,7 @@

Automatic re-acquisition

When a file has been missing for a while, Minstrel can put its album back on the - wanted list + wanted list by itself. It's wanted per album, not per track: a whole folder going missing is one want, not forty.

diff --git a/web/src/lib/components/SearchSettingsCard.svelte b/web/src/lib/components/SearchSettingsCard.svelte index f256d50a..3bc7cb26 100644 --- a/web/src/lib/components/SearchSettingsCard.svelte +++ b/web/src/lib/components/SearchSettingsCard.svelte @@ -104,7 +104,7 @@

Searching and grabbing

Minstrel searches your indexers for each album on the - wanted list, judges every + wanted list, 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.

diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index 5caf9946..d318f4d8 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -1,7 +1,7 @@ diff --git a/web/src/lib/components/Shell.test.ts b/web/src/lib/components/Shell.test.ts index 92a63aab..059a0958 100644 --- a/web/src/lib/components/Shell.test.ts +++ b/web/src/lib/components/Shell.test.ts @@ -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', () => { diff --git a/web/src/lib/components/WantedTabs.svelte b/web/src/lib/components/WantedTabs.svelte deleted file mode 100644 index 3a9bf1a4..00000000 --- a/web/src/lib/components/WantedTabs.svelte +++ /dev/null @@ -1,54 +0,0 @@ - - - diff --git a/web/src/lib/components/wanted/CatalogueArtistCard.svelte b/web/src/lib/components/wanted/CatalogueArtistCard.svelte new file mode 100644 index 00000000..88a69371 --- /dev/null +++ b/web/src/lib/components/wanted/CatalogueArtistCard.svelte @@ -0,0 +1,46 @@ + + + +
+ +
+
+
+
+
+
{a.artist_name}
+
{a.held} / {a.albums} held
+ {#if notes.length} +
{notes.join(' · ')}
+ {/if} +
+
diff --git a/web/src/lib/components/wanted/CatalogueGrid.svelte b/web/src/lib/components/wanted/CatalogueGrid.svelte new file mode 100644 index 00000000..f37478c2 --- /dev/null +++ b/web/src/lib/components/wanted/CatalogueGrid.svelte @@ -0,0 +1,110 @@ + + +{#if library && !library.listed_at} +

+ Your library's albums are being added to the catalogue. Albums whose tags don't name them are + matched on MusicBrainz first, by their artist, title and tracks, about one a second. +

+{:else if library && library.unlisted > 0} +
+

+ {library.unlisted === 1 ? '1 album' : `${library.unlisted} albums`} in your library + {library.unlisted === 1 ? "isn't" : "aren't"} in the catalogue: no MusicBrainz album matched + {library.unlisted === 1 ? 'its' : 'their'} artist, title and tracks. Until + {library.unlisted === 1 ? "it's" : "they're"} matched, {library.unlisted === 1 ? "it isn't" : "they aren't"} + fetched back if {library.unlisted === 1 ? 'its' : 'their'} files go missing. +

+ {#if library.unmatched.length > 0} + + {#if showUnmatched} +
    + {#each library.unmatched as u (u.album_id)} +
  • + {u.title} + · {u.artist} +
    {u.detail}
    +
  • + {/each} +
+ {#if library.unmatched.length < library.unlisted} +

The first {library.unmatched.length} of {library.unlisted}.

+ {/if} + {/if} + {/if} +
+{/if} + +{#if !artists.isPending && (items.length > 0 || q)} +
+ +
+{/if} + +{#if artists.isPending} +

Reading the catalogue…

+{:else if artists.isError && !artists.isFetchNextPageError} +

Couldn't load the catalogue.

+{:else if items.length === 0 && q} +

+ No artist in the catalogue, or album of theirs, is named like '{q}'. +

+{:else if items.length === 0} +
+ +

The catalogue is empty.

+

+ Your library's albums land here once it's been scanned, as do the albums users request and + the releases of artists Minstrel follows. +

+
+{:else} + a.sort_name || a.artist_name} + hasMore={!!artists.hasNextPage} + onLoadMore={() => artists.fetchNextPage()} + > + {#snippet item(a: WantArtist)} + + {/snippet} + + + artists.fetchNextPage()} + /> +{/if} diff --git a/web/src/lib/components/wanted/CatalogueGrid.test.ts b/web/src/lib/components/wanted/CatalogueGrid.test.ts new file mode 100644 index 00000000..3b4bc138 --- /dev/null +++ b/web/src/lib/components/wanted/CatalogueGrid.test.ts @@ -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 { + 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 + ); + 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(); + }); +}); diff --git a/web/src/lib/components/wanted/CollectionRail.svelte b/web/src/lib/components/wanted/CollectionRail.svelte new file mode 100644 index 00000000..a2d2e52b --- /dev/null +++ b/web/src/lib/components/wanted/CollectionRail.svelte @@ -0,0 +1,58 @@ + + + diff --git a/web/src/lib/components/wanted/WantList.svelte b/web/src/lib/components/wanted/WantList.svelte index 3771a890..bd3ae632 100644 --- a/web/src/lib/components/wanted/WantList.svelte +++ b/web/src/lib/components/wanted/WantList.svelte @@ -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(null); - let showUnmatched = $state(false); let searching = $state(null); let open = $state(null); let shown = $state(null); @@ -187,53 +188,30 @@ } -{#if list === 'held' && library} - {#if !library.listed_at} -

- 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. -

- {:else if library.unlisted > 0} -
-

- {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. -

- {#if library.unmatched.length > 0} - - {#if showUnmatched} -
    - {#each library.unmatched as u (u.album_id)} -
  • - {u.title} - · {u.artist} -
    {u.detail}
    -
  • - {/each} -
- {#if library.unmatched.length < library.unlisted} -

- The first {library.unmatched.length} of {library.unlisted}. -

- {/if} - {/if} - {/if} +{#if artist && head} +
+
+
- {/if} +
+ ← Catalogue +

{head.artist_name}

+

+ {head.held} of {albums(head.albums)} held{head.wanted ? ` · ${head.wanted} wanted` : ''}{head.missing + ? ` · ${head.missing} with files missing` + : ''}{head.downloading ? ` · ${head.downloading} downloading` : ''} +

+
+ + {#if head.artist_id} + Open in the library + {/if} +
+
+
{/if} -{#if !wants.isPending && (items.length > 0 || q)} +{#if !artist && !wants.isPending && (items.length > 0 || q)}
@@ -242,7 +220,7 @@ {#if wants.isPending}

Reading the wanted list…

{:else if wants.isError && !wants.isFetchNextPageError} -

Couldn't load the wanted list.

+

Couldn't load {artist ? "this artist's albums" : 'the wanted list'}.

{:else if items.length === 0 && q}

Nothing on this list has an artist or album named like '{q}'. @@ -251,7 +229,7 @@

- {#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}

@@ -268,16 +246,16 @@
{#each sections as a, i (a.key + ':' + i)}
-

+ {#if !artist}

- {#if a.artistId} - {a.name} + {#if a.mbid} + {a.name} {:else} {a.name} {/if} {albums(a.wants.length)} -

+ {/if}
{:else if arrived(w)} diff --git a/web/src/lib/components/wanted/WantList.test.ts b/web/src/lib/components/wanted/WantList.test.ts index 48f4f426..2ad923ea 100644 --- a/web/src/lib/components/wanted/WantList.test.ts +++ b/web/src/lib/components/wanted/WantList.test.ts @@ -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 { 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 { 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 + ); + vi.mocked(createCandidatesQuery).mockReturnValue( + mockQuery({ data: [] }) as unknown as ReturnType + ); + 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(); + }); }); diff --git a/web/src/routes/admin/missing-files/+page.svelte b/web/src/routes/admin/missing-files/+page.svelte index fb89e25d..656a3cb4 100644 --- a/web/src/routes/admin/missing-files/+page.svelte +++ b/web/src/routes/admin/missing-files/+page.svelte @@ -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 wanted list since {relativeTime(r.wanted_at)}. {/if} diff --git a/web/src/routes/admin/missing-files/missing-files.test.ts b/web/src/routes/admin/missing-files/missing-files.test.ts index cb804653..a9a113c9 100644 --- a/web/src/routes/admin/missing-files/missing-files.test.ts +++ b/web/src/routes/admin/missing-files/missing-files.test.ts @@ -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' ); }); diff --git a/web/src/routes/admin/requests/+page.svelte b/web/src/routes/admin/requests/+page.svelte index b2952c24..325eca16 100644 --- a/web/src/routes/admin/requests/+page.svelte +++ b/web/src/routes/admin/requests/+page.svelte @@ -146,7 +146,7 @@

Requests

Approve or set aside what users have asked for. An approved album or track goes on the - wanted list; an approved + wanted list; an approved artist is followed, wanting what they've released and what they release next.

diff --git a/web/src/routes/collection/+layout.svelte b/web/src/routes/collection/+layout.svelte new file mode 100644 index 00000000..3da3864e --- /dev/null +++ b/web/src/routes/collection/+layout.svelte @@ -0,0 +1,58 @@ + + +
+
+

Collection

+

+ 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 + Integrations. +

+ {#if verdict} +

+ {verdict} + {#if counts?.attention} + See Activity + {/if} +

+ {/if} +
+ +
+
+
+ +
+
+
+ {@render children?.()} +
+
+
diff --git a/web/src/routes/wanted/+layout.ts b/web/src/routes/collection/+layout.ts similarity index 100% rename from web/src/routes/wanted/+layout.ts rename to web/src/routes/collection/+layout.ts diff --git a/web/src/routes/collection/+page.svelte b/web/src/routes/collection/+page.svelte new file mode 100644 index 00000000..5a07e2e2 --- /dev/null +++ b/web/src/routes/collection/+page.svelte @@ -0,0 +1,8 @@ + + +{pageTitle('Collection')} + + diff --git a/web/src/routes/wanted/activity/+page.svelte b/web/src/routes/collection/activity/+page.svelte similarity index 67% rename from web/src/routes/wanted/activity/+page.svelte rename to web/src/routes/collection/activity/+page.svelte index c98588ee..d36f1008 100644 --- a/web/src/routes/wanted/activity/+page.svelte +++ b/web/src/routes/collection/activity/+page.svelte @@ -3,6 +3,6 @@ import DownloadList from '#lib/components/wanted/DownloadList.svelte'; -{pageTitle('Wanted · Activity')} +{pageTitle('Collection · Activity')} diff --git a/web/src/routes/collection/artist/[mbid]/+page.svelte b/web/src/routes/collection/artist/[mbid]/+page.svelte new file mode 100644 index 00000000..9732e908 --- /dev/null +++ b/web/src/routes/collection/artist/[mbid]/+page.svelte @@ -0,0 +1,15 @@ + + +{pageTitle('Collection · Artist')} + +{#key mbid} + +{/key} diff --git a/web/src/routes/wanted/blocked/+page.svelte b/web/src/routes/collection/blocked/+page.svelte similarity index 66% rename from web/src/routes/wanted/blocked/+page.svelte rename to web/src/routes/collection/blocked/+page.svelte index 3c4ea0d0..07c7b02d 100644 --- a/web/src/routes/wanted/blocked/+page.svelte +++ b/web/src/routes/collection/blocked/+page.svelte @@ -3,6 +3,6 @@ import BlockList from '#lib/components/wanted/BlockList.svelte'; -{pageTitle('Wanted · Blocked')} +{pageTitle('Collection · Blocked')} diff --git a/web/src/routes/wanted/wanted.test.ts b/web/src/routes/collection/collection.test.ts similarity index 52% rename from web/src/routes/wanted/wanted.test.ts rename to web/src/routes/collection/collection.test.ts index 4b1bb38d..ce1aab53 100644 --- a/web/src/routes/wanted/wanted.test.ts +++ b/web/src/routes/collection/collection.test.ts @@ -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: () => `

body

` })); 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 + vi.mocked(createWantArtistsQuery).mockReturnValue( + mockInfiniteQuery({ pages: [page] }) as unknown as ReturnType ); // 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'); + }); }); diff --git a/web/src/routes/wanted/following/+page.svelte b/web/src/routes/collection/following/+page.svelte similarity index 78% rename from web/src/routes/wanted/following/+page.svelte rename to web/src/routes/collection/following/+page.svelte index 7303fe95..b891a091 100644 --- a/web/src/routes/wanted/following/+page.svelte +++ b/web/src/routes/collection/following/+page.svelte @@ -4,7 +4,7 @@ import ReleaseProfilesCard from '#lib/components/ReleaseProfilesCard.svelte'; -{pageTitle('Wanted · Following')} +{pageTitle('Collection · Following')}
diff --git a/web/src/routes/wanted/missing/+page.svelte b/web/src/routes/collection/missing/+page.svelte similarity index 66% rename from web/src/routes/wanted/missing/+page.svelte rename to web/src/routes/collection/missing/+page.svelte index 35163ed2..a06ded29 100644 --- a/web/src/routes/wanted/missing/+page.svelte +++ b/web/src/routes/collection/missing/+page.svelte @@ -3,6 +3,6 @@ import WantList from '#lib/components/wanted/WantList.svelte'; -{pageTitle('Wanted · Missing files')} +{pageTitle('Collection · Missing files')} diff --git a/web/src/routes/wanted/+page.svelte b/web/src/routes/collection/wanted/+page.svelte similarity index 68% rename from web/src/routes/wanted/+page.svelte rename to web/src/routes/collection/wanted/+page.svelte index 9d6f7d36..9655cd2c 100644 --- a/web/src/routes/wanted/+page.svelte +++ b/web/src/routes/collection/wanted/+page.svelte @@ -3,6 +3,6 @@ import WantList from '#lib/components/wanted/WantList.svelte'; -{pageTitle('Wanted')} +{pageTitle('Collection · Wanted')} diff --git a/web/src/routes/wanted/+layout.svelte b/web/src/routes/wanted/+layout.svelte deleted file mode 100644 index 0e2116bf..00000000 --- a/web/src/routes/wanted/+layout.svelte +++ /dev/null @@ -1,48 +0,0 @@ - - -
-
-

Wanted

-

- 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 Integrations. -

- {#if verdict} -

- {verdict} - {#if counts?.attention} - See Activity - {/if} -

- {/if} -
- - - - {@render children?.()} -
diff --git a/web/src/routes/wanted/held/+page.svelte b/web/src/routes/wanted/held/+page.svelte deleted file mode 100644 index 652d8b20..00000000 --- a/web/src/routes/wanted/held/+page.svelte +++ /dev/null @@ -1,8 +0,0 @@ - - -{pageTitle('Wanted · Held')} - -