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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 21:14:33 -04:00
co-authored by Claude Opus 5.5
parent a4fbe44bd5
commit 358849de77
34 changed files with 994 additions and 356 deletions
+132 -50
View File
@@ -69,8 +69,10 @@ type wantGrabView struct {
ImportedFiles int32 `json:"imported_files"`
}
// wantCounts sizes each tab of the Wanted page.
// wantCounts sizes each tab of the Wanted page. Artists: how many artists
// the list holds albums of, its main page (#5572).
type wantCounts struct {
Artists int64 `json:"artists"`
Wanted int64 `json:"wanted"`
Missing int64 `json:"missing"`
Held int64 `json:"held"`
@@ -90,6 +92,34 @@ type wantsPage struct {
Counts wantCounts `json:"counts"`
// Library is the one-time listing of the library's albums (#5567).
Library libraryListingView `json:"library"`
// Artist heads one artist's page, when the list is narrowed to them.
Artist *wantArtistView `json:"artist,omitempty"`
}
// wantArtistView is one artist on the Wanted view's main page (#5572): how
// many of their albums the list holds, of how many, like Lidarr's artist
// index.
type wantArtistView struct {
ArtistMBID string `json:"artist_mbid"`
ArtistName string `json:"artist_name"`
SortName string `json:"sort_name"`
ArtistID string `json:"artist_id,omitempty"`
ImageURL string `json:"image_url"`
Albums int32 `json:"albums"`
Held int32 `json:"held"`
Wanted int32 `json:"wanted"`
Missing int32 `json:"missing"`
Downloading int32 `json:"downloading"`
Following bool `json:"following"`
}
type wantArtistsPage struct {
Items []wantArtistView `json:"items"`
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Counts wantCounts `json:"counts"`
Library libraryListingView `json:"library"`
}
// libraryListingView: whether the library's albums are all on the list
@@ -113,77 +143,129 @@ const unmatchedShown = 200
const wantsPageSize = 50
// handleListWants implements GET /api/admin/wanted?state=wanted|missing|held&q=&offset=.
// wantArtistsPageSize fills a few rows of the artist grid a page.
const wantArtistsPageSize = 96
// wantsHeader is what every Wanted page carries: each tab's count, and how
// the one-time library listing went. The albums no match was found for are
// named only when asked (the main page's first page).
func (h *handlers) wantsHeader(r *http.Request, q *dbq.Queries, unmatched bool) (wantCounts, libraryListingView, error) {
counts, err := q.CountWants(r.Context())
if err != nil {
return wantCounts{}, libraryListingView{}, err
}
listing, err := q.GetLibraryListing(r.Context())
if err != nil {
return wantCounts{}, libraryListingView{}, err
}
lib := libraryListingView{Unlisted: listing.LibraryUnlisted, Unmatched: []unmatchedAlbumView{}}
if listing.LibraryListedAt.Valid {
at := formatTimestamp(listing.LibraryListedAt)
lib.ListedAt = &at
}
if unmatched && listing.LibraryUnlisted > 0 {
rows, err := q.ListUnmatchedLibraryAlbums(r.Context(), unmatchedShown)
if err != nil {
return wantCounts{}, libraryListingView{}, err
}
for _, u := range rows {
lib.Unmatched = append(lib.Unmatched, unmatchedAlbumView{
AlbumID: uuidToString(u.AlbumID), Title: u.Title, Artist: u.ArtistName, Detail: u.Detail,
})
}
}
return wantCounts{Artists: counts.Artists, Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held,
Following: counts.Following, Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked}, lib, nil
}
func offsetParam(r *http.Request) int {
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
return max(offset, 0)
}
func wantArtistViewOf(row dbq.ListWantArtistsRow) wantArtistView {
v := wantArtistView{
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName, SortName: row.SortName,
ImageURL: artistArtURL(row.ArtistMbid, row.ArtistName),
Albums: row.Albums, Held: row.Held, Wanted: row.Wanted, Missing: row.Missing,
Downloading: row.Downloading, Following: row.Following,
}
if row.LibraryArtistID.Valid {
v.ArtistID = uuidToString(row.LibraryArtistID)
}
return v
}
// handleListWantArtists implements GET /api/admin/wanted/artists?q=&offset=:
// the Wanted view's main page, every artist on the list A–Z (#5572).
func (h *handlers) handleListWantArtists(w http.ResponseWriter, r *http.Request) {
offset := offsetParam(r)
q := dbq.New(h.pool)
rows, err := q.ListWantArtists(r.Context(), dbq.ListWantArtistsParams{
Q: strings.TrimSpace(r.URL.Query().Get("q")), Skip: int32(offset), MaxRows: wantArtistsPageSize})
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: artists", apierror.Internal(err))
return
}
counts, lib, err := h.wantsHeader(r, q, offset == 0)
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: counts", apierror.Internal(err))
return
}
out := wantArtistsPage{Items: make([]wantArtistView, 0, len(rows)), Limit: wantArtistsPageSize, Offset: offset,
Counts: counts, Library: lib}
for _, row := range rows {
out.Total = row.Total
out.Items = append(out.Items, wantArtistViewOf(row))
}
writeJSON(w, http.StatusOK, out)
}
// handleListWants implements
// GET /api/admin/wanted?state=wanted|missing|held|all&artist=&q=&offset=.
// wanted is everything open but missing files. Each list runs artist by
// artist, A–Z, like the library (#5572); q narrows it to an artist or album.
// artist, A–Z, like the library (#5572); q narrows it to an artist or
// album, and artist to one artist's page, which it then heads.
func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
if state == "" {
state = "wanted"
}
if state != "wanted" && state != "missing" && state != "held" {
writeErr(w, apierror.BadRequest("bad_state", "state must be wanted, missing or held"))
if state != "wanted" && state != "missing" && state != "held" && state != "all" {
writeErr(w, apierror.BadRequest("bad_state", "state must be wanted, missing, held or all"))
return
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
offset := offsetParam(r)
artist := strings.TrimSpace(r.URL.Query().Get("artist"))
q := dbq.New(h.pool)
filter := strings.TrimSpace(r.URL.Query().Get("q"))
rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: state, Q: filter, Skip: int32(offset), MaxRows: wantsPageSize})
rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: state, ArtistMbid: artist,
Q: strings.TrimSpace(r.URL.Query().Get("q")), Skip: int32(offset), MaxRows: wantsPageSize})
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: list", apierror.Internal(err))
return
}
counts, err := q.CountWants(r.Context())
counts, lib, err := h.wantsHeader(r, q, false)
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: count", apierror.Internal(err))
writeErrWithLog(w, h.logger, "admin wanted: counts", apierror.Internal(err))
return
}
listing, err := q.GetLibraryListing(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: library listing", apierror.Internal(err))
return
}
out := wantsPage{
Library: libraryListingView{Unlisted: listing.LibraryUnlisted},
Items: make([]wantView, 0, len(rows)), Limit: wantsPageSize, Offset: offset,
Counts: wantCounts{Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held, Following: counts.Following,
Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked},
}
out.Library.Unmatched = []unmatchedAlbumView{}
if listing.LibraryListedAt.Valid {
at := formatTimestamp(listing.LibraryListedAt)
out.Library.ListedAt = &at
}
if state == "held" && offset == 0 && listing.LibraryUnlisted > 0 {
unmatched, err := q.ListUnmatchedLibraryAlbums(r.Context(), unmatchedShown)
out := wantsPage{Library: lib, Counts: counts,
Items: make([]wantView, 0, len(rows)), Limit: wantsPageSize, Offset: offset}
if artist != "" {
heads, err := q.ListWantArtists(r.Context(), dbq.ListWantArtistsParams{ArtistMbid: artist, MaxRows: 1})
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: unmatched albums", apierror.Internal(err))
writeErrWithLog(w, h.logger, "admin wanted: artist", apierror.Internal(err))
return
}
for _, u := range unmatched {
out.Library.Unmatched = append(out.Library.Unmatched, unmatchedAlbumView{
AlbumID: uuidToString(u.AlbumID), Title: u.Title, Artist: u.ArtistName, Detail: u.Detail,
})
}
}
switch state {
case "missing":
out.Total = counts.Missing
case "held":
out.Total = counts.Held
default:
out.Total = counts.Wanted
}
if filter != "" {
out.Total = 0
if len(rows) > 0 {
out.Total = rows[0].Total
if len(heads) == 0 {
writeErr(w, apierror.NotFound("artist"))
return
}
v := wantArtistViewOf(heads[0])
out.Artist = &v
}
for _, row := range rows {
out.Total = row.Total
v := wantView{
ID: uuidToString(row.ID), ReleaseGroupMBID: row.ReleaseGroupMbid, Title: row.Title,
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName,
+70
View File
@@ -24,6 +24,7 @@ func doWantedReq(t *testing.T, h *handlers, path string, user dbq.User) *httptes
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/wanted", h.handleListWants)
admin.Get("/wanted/artists", h.handleListWantArtists)
admin.Get("/wanted/{id}/tracks", h.handleWantTracks)
})
w := httptest.NewRecorder()
@@ -98,6 +99,75 @@ func TestListWants_ArtistsAToZThenTheirAlbumsByDate(t *testing.T) {
}
}
// The Collection's catalogue (#5572): every artist on the list A–Z, each
// with how many of their albums are held; an artist's page lists every album
// of theirs, held or not, under that artist.
func TestListWantArtists_CatalogueAndAnArtistsPage(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
q := dbq.New(h.pool)
beatles := "a0000000-0000-4000-8000-000000000002"
camel := "a0000000-0000-4000-8000-000000000001"
if _, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "The Beatles", SortName: "Beatles, The", Mbid: &beatles}); err != nil {
t.Fatal(err)
}
seedWant(t, h, "g0000000-0000-4000-8000-000000000002", beatles, "The Beatles", "Abbey Road", "1969")
held := seedWant(t, h, "g0000000-0000-4000-8000-000000000003", beatles, "The Beatles", "Please Please Me", "1963")
if _, err := h.pool.Exec(ctx, `UPDATE wants SET fulfilled_at = now() WHERE id = $1`, held.ID); err != nil {
t.Fatal(err)
}
seedWant(t, h, "g0000000-0000-4000-8000-000000000004", camel, "Camel", "Moonmadness", "1976")
if _, err := h.pool.Exec(ctx, `INSERT INTO follows (artist_mbid, artist_name, profile_id)
SELECT $1, 'Camel', id FROM release_type_profiles WHERE is_default`, camel); err != nil {
t.Fatal(err)
}
w := doWantedReq(t, h, "/api/admin/wanted/artists", admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
}
var page wantArtistsPage
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
if len(page.Items) != 2 || page.Total != 2 || page.Counts.Artists != 2 {
t.Fatalf("catalogue = %+v, total %d, artists %d", page.Items, page.Total, page.Counts.Artists)
}
b, c := page.Items[0], page.Items[1]
if b.ArtistName != "The Beatles" || b.SortName != "Beatles, The" || b.Albums != 2 || b.Held != 1 || b.Wanted != 1 ||
b.ArtistID == "" || b.Following || b.ImageURL == "" {
t.Errorf("Beatles = %+v", b)
}
if c.ArtistName != "Camel" || c.Albums != 1 || c.Held != 0 || !c.Following || c.ArtistID != "" {
t.Errorf("Camel = %+v", c)
}
// q finds an artist by an album's title.
w = doWantedReq(t, h, "/api/admin/wanted/artists?q=moon", admin)
page = wantArtistsPage{}
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.Items[0].ArtistName != "Camel" {
t.Errorf("q=moon: %+v", page.Items)
}
// The Beatles' page: both albums, held or not, headed by the artist.
w = doWantedReq(t, h, "/api/admin/wanted?state=all&artist="+beatles, admin)
var albums wantsPage
if err := json.Unmarshal(w.Body.Bytes(), &albums); err != nil {
t.Fatal(err)
}
if len(albums.Items) != 2 || albums.Items[0].Title != "Please Please Me" || albums.Items[0].HeldAt == nil ||
albums.Artist == nil || albums.Artist.Held != 1 {
t.Errorf("artist page = %+v, head %+v", albums.Items, albums.Artist)
}
if w := doWantedReq(t, h, "/api/admin/wanted?state=all&artist=nobody", admin); w.Code != http.StatusNotFound {
t.Errorf("an artist with nothing on the list: status %d, want 404", w.Code)
}
}
// fakeEditions is a MusicBrainz holding one group with two editions, the
// shorter official one the standard, or 503 when down is set.
func fakeEditions(t *testing.T, group string, down bool) *httptest.Server {
+1
View File
@@ -252,6 +252,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
// for, the artists it follows, and the release-type
// profiles that decide which of their releases count.
admin.Get("/wanted", h.handleListWants)
admin.Get("/wanted/artists", h.handleListWantArtists)
admin.Delete("/wanted/{id}", h.handleRemoveWant)
// Searching and grabbing (M502 #5521).
admin.Get("/acquisition/settings", h.handleGetSearchSettings)
+108 -9
View File
@@ -37,6 +37,7 @@ const countWants = `-- name: CountWants :one
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
COUNT(DISTINCT artist_mbid)::bigint AS artists,
(SELECT COUNT(*) FROM follows)::bigint AS following,
-- The Wanted view's other tabs (#5522): downloads running, those
-- waiting on a person, and releases set aside.
@@ -52,6 +53,7 @@ type CountWantsRow struct {
Wanted int64
Missing int64
Held int64
Artists int64
Following int64
Activity int64
Attention int64
@@ -65,6 +67,7 @@ func (q *Queries) CountWants(ctx context.Context) (CountWantsRow, error) {
&i.Wanted,
&i.Missing,
&i.Held,
&i.Artists,
&i.Following,
&i.Activity,
&i.Attention,
@@ -852,6 +855,98 @@ func (q *Queries) ListUnmatchedLibraryAlbums(ctx context.Context, maxRows int32)
return items, nil
}
const listWantArtists = `-- name: ListWantArtists :many
WITH a AS (
SELECT w.artist_mbid,
(array_agg(w.artist_name ORDER BY w.wanted_at DESC))[1] AS artist_name,
COUNT(*) FILTER (WHERE w.fulfilled_at IS NOT NULL)::int AS held,
COUNT(*) FILTER (WHERE w.fulfilled_at IS NULL AND w.reason <> 'missing')::int AS wanted,
COUNT(*) FILTER (WHERE w.fulfilled_at IS NULL AND w.reason = 'missing')::int AS missing,
COUNT(*)::int AS albums,
bool_or($1::text <> '' AND strpos(lower(w.title), lower($1::text)) > 0) AS title_match
FROM wants w
WHERE $4::text = '' OR w.artist_mbid = $4::text
GROUP BY w.artist_mbid
)
SELECT a.artist_mbid, a.artist_name::text AS artist_name, a.held, a.wanted, a.missing, a.albums,
COALESCE(NULLIF(la.sort_name, ''), a.artist_name)::text AS sort_name,
la.id AS library_artist_id,
(f.artist_mbid IS NOT NULL)::bool AS following,
(SELECT COUNT(*) FROM grabs g JOIN wants x ON x.id = g.want_id
WHERE x.artist_mbid = a.artist_mbid AND g.ended_at IS NULL)::int AS downloading,
COUNT(*) OVER () AS total
FROM a
LEFT JOIN LATERAL (SELECT ar.id, ar.sort_name FROM artists ar WHERE ar.mbid = a.artist_mbid LIMIT 1) la ON true
LEFT JOIN follows f ON f.artist_mbid = a.artist_mbid
WHERE $1::text = ''
OR strpos(lower(a.artist_name), lower($1::text)) > 0
OR a.title_match
ORDER BY lower(COALESCE(NULLIF(la.sort_name, ''), a.artist_name)), a.artist_mbid
LIMIT $3 OFFSET $2
`
type ListWantArtistsParams struct {
Q string
Skip int32
MaxRows int32
ArtistMbid string
}
type ListWantArtistsRow struct {
ArtistMbid string
ArtistName string
Held int32
Wanted int32
Missing int32
Albums int32
SortName string
LibraryArtistID pgtype.UUID
Following bool
Downloading int32
Total int64
}
// The Wanted view's main page (#5572): every artist on the list, A–Z like
// the library, with how many of their albums are held of how many are on
// the list. q matches the artist's name or any of their albums' titles;
// artist_mbid narrows to one artist, for their page's header.
func (q *Queries) ListWantArtists(ctx context.Context, arg ListWantArtistsParams) ([]ListWantArtistsRow, error) {
rows, err := q.db.Query(ctx, listWantArtists,
arg.Q,
arg.Skip,
arg.MaxRows,
arg.ArtistMbid,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListWantArtistsRow
for rows.Next() {
var i ListWantArtistsRow
if err := rows.Scan(
&i.ArtistMbid,
&i.ArtistName,
&i.Held,
&i.Wanted,
&i.Missing,
&i.Albums,
&i.SortName,
&i.LibraryArtistID,
&i.Following,
&i.Downloading,
&i.Total,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWants = `-- name: ListWants :many
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason,
w.album_id, w.wanted_at, w.fulfilled_at,
@@ -879,24 +974,27 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
LEFT JOIN grabs gr ON gr.id = (SELECT x.id FROM grabs x WHERE x.want_id = w.id
ORDER BY x.ended_at IS NULL DESC, x.grabbed_at DESC LIMIT 1)
WHERE CASE $1::text
WHEN 'all' THEN true
WHEN 'held' THEN w.fulfilled_at IS NOT NULL
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
END
AND ($2::text = ''
OR strpos(lower(w.artist_name), lower($2::text)) > 0
OR strpos(lower(w.title), lower($2::text)) > 0)
AND ($2::text = '' OR w.artist_mbid = $2::text)
AND ($3::text = ''
OR strpos(lower(w.artist_name), lower($3::text)) > 0
OR strpos(lower(w.title), lower($3::text)) > 0)
ORDER BY lower(COALESCE(NULLIF(la.sort_name, ''), w.artist_name)), w.artist_mbid,
NULLIF(COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, ''), '') NULLS LAST,
lower(w.title), w.id
LIMIT $4 OFFSET $3
LIMIT $5 OFFSET $4
`
type ListWantsParams struct {
State string
Q string
Skip int32
MaxRows int32
State string
ArtistMbid string
Q string
Skip int32
MaxRows int32
}
type ListWantsRow struct {
@@ -937,13 +1035,14 @@ type ListWantsRow struct {
}
// The Wanted page: open wants (all but missing ones, or only missing ones),
// or the held ones. Ordered like the library (#5572): artists A–Z by the
// the held ones, or all of them; artist_mbid narrows to one artist's page. Ordered like the library (#5572): artists A–Z by the
// library's sort name when it holds the artist, each artist's albums by
// release date, so the page groups them as they arrive. q narrows to an
// artist or album title; total counts every match, past the page.
func (q *Queries) ListWants(ctx context.Context, arg ListWantsParams) ([]ListWantsRow, error) {
rows, err := q.db.Query(ctx, listWants,
arg.State,
arg.ArtistMbid,
arg.Q,
arg.Skip,
arg.MaxRows,
+37 -1
View File
@@ -177,7 +177,7 @@ DELETE FROM wants WHERE id = $1;
-- name: ListWants :many
-- The Wanted page: open wants (all but missing ones, or only missing ones),
-- or the held ones. Ordered like the library (#5572): artists A–Z by the
-- the held ones, or all of them; artist_mbid narrows to one artist's page. Ordered like the library (#5572): artists A–Z by the
-- library's sort name when it holds the artist, each artist's albums by
-- release date, so the page groups them as they arrive. q narrows to an
-- artist or album title; total counts every match, past the page.
@@ -207,10 +207,12 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
LEFT JOIN grabs gr ON gr.id = (SELECT x.id FROM grabs x WHERE x.want_id = w.id
ORDER BY x.ended_at IS NULL DESC, x.grabbed_at DESC LIMIT 1)
WHERE CASE sqlc.arg(state)::text
WHEN 'all' THEN true
WHEN 'held' THEN w.fulfilled_at IS NOT NULL
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
END
AND (sqlc.arg(artist_mbid)::text = '' OR w.artist_mbid = sqlc.arg(artist_mbid)::text)
AND (sqlc.arg(q)::text = ''
OR strpos(lower(w.artist_name), lower(sqlc.arg(q)::text)) > 0
OR strpos(lower(w.title), lower(sqlc.arg(q)::text)) > 0)
@@ -219,6 +221,39 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
lower(w.title), w.id
LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
-- name: ListWantArtists :many
-- The Wanted view's main page (#5572): every artist on the list, A–Z like
-- the library, with how many of their albums are held of how many are on
-- the list. q matches the artist's name or any of their albums' titles;
-- artist_mbid narrows to one artist, for their page's header.
WITH a AS (
SELECT w.artist_mbid,
(array_agg(w.artist_name ORDER BY w.wanted_at DESC))[1] AS artist_name,
COUNT(*) FILTER (WHERE w.fulfilled_at IS NOT NULL)::int AS held,
COUNT(*) FILTER (WHERE w.fulfilled_at IS NULL AND w.reason <> 'missing')::int AS wanted,
COUNT(*) FILTER (WHERE w.fulfilled_at IS NULL AND w.reason = 'missing')::int AS missing,
COUNT(*)::int AS albums,
bool_or(sqlc.arg(q)::text <> '' AND strpos(lower(w.title), lower(sqlc.arg(q)::text)) > 0) AS title_match
FROM wants w
WHERE sqlc.arg(artist_mbid)::text = '' OR w.artist_mbid = sqlc.arg(artist_mbid)::text
GROUP BY w.artist_mbid
)
SELECT a.artist_mbid, a.artist_name::text AS artist_name, a.held, a.wanted, a.missing, a.albums,
COALESCE(NULLIF(la.sort_name, ''), a.artist_name)::text AS sort_name,
la.id AS library_artist_id,
(f.artist_mbid IS NOT NULL)::bool AS following,
(SELECT COUNT(*) FROM grabs g JOIN wants x ON x.id = g.want_id
WHERE x.artist_mbid = a.artist_mbid AND g.ended_at IS NULL)::int AS downloading,
COUNT(*) OVER () AS total
FROM a
LEFT JOIN LATERAL (SELECT ar.id, ar.sort_name FROM artists ar WHERE ar.mbid = a.artist_mbid LIMIT 1) la ON true
LEFT JOIN follows f ON f.artist_mbid = a.artist_mbid
WHERE sqlc.arg(q)::text = ''
OR strpos(lower(a.artist_name), lower(sqlc.arg(q)::text)) > 0
OR a.title_match
ORDER BY lower(COALESCE(NULLIF(la.sort_name, ''), a.artist_name)), a.artist_mbid
LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
-- name: GetWantAlbum :one
-- What the Wanted view's track list needs of a want (#5572).
SELECT id, release_group_mbid, album_id FROM wants WHERE id = $1;
@@ -235,6 +270,7 @@ SELECT title, disc_number, track_number, duration_ms, (missing_since IS NOT NULL
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
COUNT(DISTINCT artist_mbid)::bigint AS artists,
(SELECT COUNT(*) FROM follows)::bigint AS following,
-- The Wanted view's other tabs (#5522): downloads running, those
-- waiting on a person, and releases set aside.