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
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:
+132
-50
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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)}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 />
|
||||
+1
-1
@@ -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}
|
||||
+1
-1
@@ -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 />
|
||||
+28
-23
@@ -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');
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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 />
|
||||
+1
-1
@@ -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" />
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
Reference in New Issue
Block a user