diff --git a/internal/api/admin_wanted.go b/internal/api/admin_wanted.go index 7ed0b292..852d9cda 100644 --- a/internal/api/admin_wanted.go +++ b/internal/api/admin_wanted.go @@ -4,12 +4,14 @@ import ( "errors" "net/http" "strconv" + "strings" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/catalogue" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz" "git.fabledsword.com/bvandeusen/minstrel/internal/wanted" @@ -111,8 +113,9 @@ const unmatchedShown = 200 const wantsPageSize = 50 -// handleListWants implements GET /api/admin/wanted?state=wanted|missing|held&offset=. -// wanted is everything open but missing files; held is newest first. +// handleListWants implements GET /api/admin/wanted?state=wanted|missing|held&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. func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) { state := r.URL.Query().Get("state") if state == "" { @@ -127,7 +130,8 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) { offset = 0 } q := dbq.New(h.pool) - rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: state, Skip: int32(offset), MaxRows: wantsPageSize}) + 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}) if err != nil { writeErrWithLog(w, h.logger, "admin wanted: list", apierror.Internal(err)) return @@ -173,6 +177,12 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) { default: out.Total = counts.Wanted } + if filter != "" { + out.Total = 0 + if len(rows) > 0 { + out.Total = rows[0].Total + } + } for _, row := range rows { v := wantView{ ID: uuidToString(row.ID), ReleaseGroupMBID: row.ReleaseGroupMbid, Title: row.Title, @@ -227,6 +237,126 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) } +// wantTracksView is an album's track list for the Wanted view (#5572): the +// library's own tracks for an album it holds or held, else MusicBrainz's +// standard edition of what is wanted. +type wantTracksView struct { + // library, musicbrainz, or none when MusicBrainz lists no edition. + Source string `json:"source"` + // Edition names the MusicBrainz release the list is from. + Edition string `json:"edition,omitempty"` + Tracks []wantTrackView `json:"tracks"` +} + +type wantTrackView struct { + Disc int32 `json:"disc"` + Number int32 `json:"number"` + Title string `json:"title"` + LengthMs int32 `json:"length_ms"` + // Missing: the library's file for it is gone. + Missing bool `json:"missing"` +} + +// handleWantTracks implements GET /api/admin/wanted/{id}/tracks. +func (h *handlers) handleWantTracks(w http.ResponseWriter, r *http.Request) { + id, ok := requireURLUUID(w, r, "id") + if !ok { + return + } + q := dbq.New(h.pool) + want, err := q.GetWantAlbum(r.Context(), id) + if errors.Is(err, pgx.ErrNoRows) { + writeErr(w, apierror.NotFound("want")) + return + } + if err != nil { + writeErrWithLog(w, h.logger, "admin wanted: tracks", apierror.Internal(err)) + return + } + out := wantTracksView{Tracks: []wantTrackView{}} + if want.AlbumID.Valid { + rows, err := q.ListLibraryAlbumTracks(r.Context(), want.AlbumID) + if err != nil { + writeErrWithLog(w, h.logger, "admin wanted: library tracks", apierror.Internal(err)) + return + } + out.Source = "library" + for _, t := range rows { + v := wantTrackView{Title: t.Title, LengthMs: t.DurationMs, Missing: t.Missing} + if t.DiscNumber != nil { + v.Disc = *t.DiscNumber + } + if t.TrackNumber != nil { + v.Number = *t.TrackNumber + } + out.Tracks = append(out.Tracks, v) + } + writeJSON(w, http.StatusOK, out) + return + } + eds, err := h.catalogue.Releases(r.Context(), want.ReleaseGroupMbid) + switch { + case errors.Is(err, catalogue.ErrNotFound): + out.Source = "none" + writeJSON(w, http.StatusOK, out) + return + case errors.Is(err, musicbrainz.ErrUnreachable), errors.Is(err, musicbrainz.ErrBadResponse): + writeErr(w, &apierror.Error{Status: 503, Code: "musicbrainz_unreachable", Message: "MusicBrainz can't be reached", Cause: err}) + return + case err != nil: + writeErrWithLog(w, h.logger, "admin wanted: editions", apierror.Internal(err)) + return + } + e, ok := standardEdition(eds.Editions) + if !ok { + out.Source = "none" + writeJSON(w, http.StatusOK, out) + return + } + out.Source = "musicbrainz" + out.Edition = editionLabel(e.Release) + for _, t := range e.Tracks { + out.Tracks = append(out.Tracks, wantTrackView{Disc: t.MediumPosition, Number: t.Position, Title: t.Title, LengthMs: t.LengthMs}) + } + writeJSON(w, http.StatusOK, out) +} + +// standardEdition is the edition a person would call the album: an official +// one with the fewest tracks, else any with the fewest. The catalogue lists +// official editions first by date, so a tie goes to the earliest. +func standardEdition(eds []catalogue.Edition) (catalogue.Edition, bool) { + best := -1 + for i, e := range eds { + if len(e.Tracks) == 0 { + continue + } + if best < 0 { + best = i + continue + } + b := eds[best] + bo, eo := b.Release.Status == "Official", e.Release.Status == "Official" + if (eo && !bo) || (eo == bo && len(e.Tracks) < len(b.Tracks)) { + best = i + } + } + if best < 0 { + return catalogue.Edition{}, false + } + return eds[best], true +} + +// editionLabel names a release for a person: "2023 · CD · GB". +func editionLabel(r dbq.MbRelease) string { + var parts []string + for _, p := range []string{first4(r.ReleaseDate), r.Formats, r.Country} { + if p != "" { + parts = append(parts, p) + } + } + return strings.Join(parts, " · ") +} + func first4(date string) string { if len(date) < 4 { return "" diff --git a/internal/api/admin_wanted_test.go b/internal/api/admin_wanted_test.go new file mode 100644 index 00000000..db7f9baa --- /dev/null +++ b/internal/api/admin_wanted_test.go @@ -0,0 +1,213 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/catalogue" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz" + "git.fabledsword.com/bvandeusen/minstrel/internal/wanted" +) + +func doWantedReq(t *testing.T, h *handlers, path string, user dbq.User) *httptest.ResponseRecorder { + t.Helper() + r := chi.NewRouter() + r.Route("/api/admin", func(admin chi.Router) { + admin.Use(auth.RequireAdmin()) + admin.Get("/wanted", h.handleListWants) + admin.Get("/wanted/{id}/tracks", h.handleWantTracks) + }) + w := httptest.NewRecorder() + r.ServeHTTP(w, withUser(httptest.NewRequest(http.MethodGet, path, nil), user)) + return w +} + +func seedWant(t *testing.T, h *handlers, group, artistMBID, artist, title, date string) dbq.Want { + t.Helper() + w, err := wanted.WantWith(context.Background(), dbq.New(h.pool), wanted.WantParams{ + GroupMBID: group, ArtistMBID: artistMBID, ArtistName: artist, Title: title, + PrimaryType: "Album", FirstReleaseDate: date, Reason: wanted.ReasonFollow, + }) + if err != nil { + t.Fatal(err) + } + return w +} + +func listWanted(t *testing.T, h *handlers, user dbq.User, q string) wantsPage { + t.Helper() + w := doWantedReq(t, h, "/api/admin/wanted?"+url.Values{"q": {q}}.Encode(), user) + if w.Code != http.StatusOK { + t.Fatalf("status = %d; body = %s", w.Code, w.Body.String()) + } + var page wantsPage + if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil { + t.Fatal(err) + } + return page +} + +// The list runs like the library (#5572): artists A–Z by the library's sort +// name where the library holds the artist, each artist's albums oldest +// first; q narrows it to an artist or an album. +func TestListWants_ArtistsAToZThenTheirAlbumsByDate(t *testing.T) { + h, _ := testHandlersWithClientFn(t) + admin := seedUser(t, h.pool, "admin", "pw", true) + ctx := context.Background() + beatles := "a0000000-0000-4000-8000-000000000002" + if _, err := dbq.New(h.pool).UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "The Beatles", SortName: "Beatles, The", Mbid: &beatles}); err != nil { + t.Fatal(err) + } + seedWant(t, h, "g0000000-0000-4000-8000-000000000001", "a0000000-0000-4000-8000-000000000003", "Zeds", "Zed Album", "2001") + seedWant(t, h, "g0000000-0000-4000-8000-000000000002", beatles, "The Beatles", "Abbey Road", "1969-09-26") + seedWant(t, h, "g0000000-0000-4000-8000-000000000003", beatles, "The Beatles", "Please Please Me", "1963-03-22") + seedWant(t, h, "g0000000-0000-4000-8000-000000000004", "a0000000-0000-4000-8000-000000000001", "Camel", "Moonmadness", "1976") + + page := listWanted(t, h, admin, "") + var got []string + for _, it := range page.Items { + got = append(got, it.ArtistName+"/"+it.Title) + } + want := "[The Beatles/Please Please Me The Beatles/Abbey Road Camel/Moonmadness Zeds/Zed Album]" + if fmt.Sprint(got) != want { + t.Errorf("order = %v, want %s", got, want) + } + if page.Items[0].ArtistID == "" || page.Items[2].ArtistID != "" { + t.Errorf("artist ids = %q, %q: only an artist the library holds has one", page.Items[0].ArtistID, page.Items[2].ArtistID) + } + + page = listWanted(t, h, admin, "BEATLES") + if len(page.Items) != 2 || page.Total != 2 { + t.Errorf("q=BEATLES: %d items, total %d; want the two Beatles albums", len(page.Items), page.Total) + } + page = listWanted(t, h, admin, "moon") + if len(page.Items) != 1 || page.Items[0].Title != "Moonmadness" || page.Total != 1 { + t.Errorf("q=moon: %+v, total %d", page.Items, page.Total) + } + if page = listWanted(t, h, admin, "nothing like it"); len(page.Items) != 0 || page.Total != 0 { + t.Errorf("no match: %d items, total %d", len(page.Items), page.Total) + } +} + +// 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 { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if down { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + switch { + case r.URL.Path == "/release-group/"+group: + _, _ = fmt.Fprintf(w, `{"id": %q, "title": "Moonmadness", "primary-type": "Album", "first-release-date": "1976"}`, group) + case r.URL.Path == "/release" && r.URL.Query().Get("release-group") == group: + _, _ = fmt.Fprint(w, `{"release-count": 2, "releases": [ + {"id": "r-deluxe", "title": "Moonmadness", "status": "Official", "date": "2009", "country": "GB", + "media": [{"position": 1, "format": "CD", "tracks": [ + {"position": 1, "number": "1", "title": "Aristillus", "length": 120000, "recording": {"id": "rec-1"}}, + {"position": 2, "number": "2", "title": "Song Within a Song", "length": 433000, "recording": {"id": "rec-2"}}, + {"position": 3, "number": "3", "title": "Bonus", "length": 200000, "recording": {"id": "rec-3"}}]}]}, + {"id": "r-orig", "title": "Moonmadness", "status": "Official", "date": "1976-03", "country": "GB", + "media": [{"position": 1, "format": "12\" Vinyl", "tracks": [ + {"position": 1, "number": "A1", "title": "Aristillus", "length": 120000, "recording": {"id": "rec-1"}}, + {"position": 2, "number": "A2", "title": "Song Within a Song", "length": 433000, "recording": {"id": "rec-2"}}]}]}]}`) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func wantTracks(t *testing.T, h *handlers, user dbq.User, id string) (int, wantTracksView) { + t.Helper() + w := doWantedReq(t, h, "/api/admin/wanted/"+id+"/tracks", user) + var out wantTracksView + if w.Code == http.StatusOK { + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + } + return w.Code, out +} + +// An album not yet held shows MusicBrainz's standard edition: the shortest +// official one. +func TestWantTracks_StandardEditionFromMusicBrainz(t *testing.T) { + const group = "g0000000-0000-4000-8000-000000000010" + for _, down := range []bool{false, true} { + h, pool := testHandlersWithClientFn(t) + admin := seedUser(t, h.pool, "admin", "pw", true) + h.catalogue = catalogue.New(pool, &musicbrainz.Client{BaseURL: fakeEditions(t, group, down).URL, + Pace: &musicbrainz.Pacer{}, Retries: -1}, h.logger) + w := seedWant(t, h, group, "a0000000-0000-4000-8000-000000000001", "Camel", "Moonmadness", "1976") + + code, out := wantTracks(t, h, admin, uuidToString(w.ID)) + if down { + if code != http.StatusServiceUnavailable { + t.Errorf("MusicBrainz down: status %d, want 503", code) + } + continue + } + if code != http.StatusOK || out.Source != "musicbrainz" || len(out.Tracks) != 2 { + t.Fatalf("status %d, %+v: want the two-track original", code, out) + } + if out.Edition != "1976 · 12\" Vinyl · GB" { + t.Errorf("edition = %q", out.Edition) + } + if tr := out.Tracks[1]; tr.Disc != 1 || tr.Number != 2 || tr.Title != "Song Within a Song" || tr.LengthMs != 433000 { + t.Errorf("track 2 = %+v", tr) + } + } +} + +// A held album shows the library's own tracks, a lost file marked. +func TestWantTracks_HeldAlbumShowsTheLibrarysTracks(t *testing.T) { + h, _ := testHandlersWithClientFn(t) + admin := seedUser(t, h.pool, "admin", "pw", true) + ctx := context.Background() + q := dbq.New(h.pool) + mbid := "a0000000-0000-4000-8000-000000000001" + artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Camel", SortName: "Camel", Mbid: &mbid}) + if err != nil { + t.Fatal(err) + } + album, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Moonmadness", SortTitle: "Moonmadness", ArtistID: artist.ID}) + if err != nil { + t.Fatal(err) + } + for i, title := range []string{"Aristillus", "Song Within a Song"} { + num := int32(i + 1) + if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{ + Title: title, AlbumID: album.ID, ArtistID: artist.ID, TrackNumber: &num, DurationMs: 120000, + FilePath: fmt.Sprintf("/music/camel/%02d.flac", i+1), FileSize: 1, FileFormat: "flac", + }); err != nil { + t.Fatal(err) + } + } + if _, err := h.pool.Exec(ctx, `UPDATE tracks SET missing_since = now() WHERE title = 'Aristillus'`); err != nil { + t.Fatal(err) + } + w := seedWant(t, h, "g0000000-0000-4000-8000-000000000011", mbid, "Camel", "Moonmadness", "1976") + if err := q.FulfillWant(ctx, dbq.FulfillWantParams{ID: w.ID, AlbumID: album.ID}); err != nil { + t.Fatal(err) + } + + code, out := wantTracks(t, h, admin, uuidToString(w.ID)) + if code != http.StatusOK || out.Source != "library" || len(out.Tracks) != 2 { + t.Fatalf("status %d, %+v", code, out) + } + if !out.Tracks[0].Missing || out.Tracks[1].Missing || out.Tracks[1].Number != 2 { + t.Errorf("tracks = %+v: the first is missing, the second on disk", out.Tracks) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index 1b7a0225..68825e18 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -257,6 +257,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Get("/acquisition/settings", h.handleGetSearchSettings) admin.Put("/acquisition/settings", h.handlePutSearchSettings) admin.Get("/wanted/{id}/candidates", h.handleListCandidates) + admin.Get("/wanted/{id}/tracks", h.handleWantTracks) admin.Post("/wanted/{id}/search", h.handleSearchWant) admin.Post("/wanted/{id}/grab", h.handleGrabCandidate) admin.Post("/wanted/{id}/block", h.handleBlockCandidate) diff --git a/internal/db/dbq/wanted.sql.go b/internal/db/dbq/wanted.sql.go index 4699d712..c33da297 100644 --- a/internal/db/dbq/wanted.sql.go +++ b/internal/db/dbq/wanted.sql.go @@ -273,6 +273,24 @@ func (q *Queries) GetReleaseTypeProfile(ctx context.Context, id pgtype.UUID) (Re return i, err } +const getWantAlbum = `-- name: GetWantAlbum :one +SELECT id, release_group_mbid, album_id FROM wants WHERE id = $1 +` + +type GetWantAlbumRow struct { + ID pgtype.UUID + ReleaseGroupMbid string + AlbumID pgtype.UUID +} + +// What the Wanted view's track list needs of a want (#5572). +func (q *Queries) GetWantAlbum(ctx context.Context, id pgtype.UUID) (GetWantAlbumRow, error) { + row := q.db.QueryRow(ctx, getWantAlbum, id) + var i GetWantAlbumRow + err := row.Scan(&i.ID, &i.ReleaseGroupMbid, &i.AlbumID) + return i, err +} + const getWantByReleaseGroup = `-- name: GetWantByReleaseGroup :one SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at, searched_at, search_outcome, search_considered, best_release, best_verdict, search_error FROM wants WHERE release_group_mbid = $1 ` @@ -616,6 +634,49 @@ func (q *Queries) ListFollowsDue(ctx context.Context, arg ListFollowsDueParams) return items, nil } +const listLibraryAlbumTracks = `-- name: ListLibraryAlbumTracks :many +SELECT title, disc_number, track_number, duration_ms, (missing_since IS NOT NULL)::bool AS missing + FROM tracks + WHERE album_id = $1 + ORDER BY disc_number NULLS FIRST, track_number NULLS LAST, title +` + +type ListLibraryAlbumTracksRow struct { + Title string + DiscNumber *int32 + TrackNumber *int32 + DurationMs int32 + Missing bool +} + +// A held or missing album's own tracks, for the Wanted view's track list; +// missing says its file is gone. +func (q *Queries) ListLibraryAlbumTracks(ctx context.Context, albumID pgtype.UUID) ([]ListLibraryAlbumTracksRow, error) { + rows, err := q.db.Query(ctx, listLibraryAlbumTracks, albumID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListLibraryAlbumTracksRow + for rows.Next() { + var i ListLibraryAlbumTracksRow + if err := rows.Scan( + &i.Title, + &i.DiscNumber, + &i.TrackNumber, + &i.DurationMs, + &i.Missing, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listLibraryAlbums = `-- name: ListLibraryAlbums :execrows INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id, wanted_at, fulfilled_at) SELECT DISTINCT ON (a.release_group_mbid) @@ -800,7 +861,8 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas COALESCE(g.secondary_types, '{}')::text[] AS secondary_types, COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date, u.username AS requested_by, - (SELECT ar.id FROM artists ar WHERE ar.mbid = w.artist_mbid) AS library_artist_id, + la.id AS library_artist_id, + COUNT(*) OVER () AS total, -- The search schedule's last look (#5521), and the download running. w.searched_at, w.search_outcome, w.search_considered, w.best_release, w.best_verdict, w.search_error, -- Its latest download, running or ended, and what the import made of @@ -813,6 +875,7 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas FROM wants w LEFT JOIN users u ON u.id = w.created_by LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid + LEFT JOIN LATERAL (SELECT ar.id, ar.sort_name FROM artists ar WHERE ar.mbid = w.artist_mbid LIMIT 1) la ON true 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 @@ -820,12 +883,18 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing' ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing' END - ORDER BY COALESCE(w.fulfilled_at, w.wanted_at) DESC, w.id - LIMIT $3 OFFSET $2 + AND ($2::text = '' + OR strpos(lower(w.artist_name), lower($2::text)) > 0 + OR strpos(lower(w.title), lower($2::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 ` type ListWantsParams struct { State string + Q string Skip int32 MaxRows int32 } @@ -845,6 +914,7 @@ type ListWantsRow struct { FirstReleaseDate string RequestedBy *string LibraryArtistID pgtype.UUID + Total int64 SearchedAt pgtype.Timestamptz SearchOutcome string SearchConsidered int32 @@ -867,9 +937,17 @@ type ListWantsRow struct { } // The Wanted page: open wants (all but missing ones, or only missing ones), -// or what was held most recently. +// or the held ones. 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.Skip, arg.MaxRows) + rows, err := q.db.Query(ctx, listWants, + arg.State, + arg.Q, + arg.Skip, + arg.MaxRows, + ) if err != nil { return nil, err } @@ -892,6 +970,7 @@ func (q *Queries) ListWants(ctx context.Context, arg ListWantsParams) ([]ListWan &i.FirstReleaseDate, &i.RequestedBy, &i.LibraryArtistID, + &i.Total, &i.SearchedAt, &i.SearchOutcome, &i.SearchConsidered, diff --git a/internal/db/queries/wanted.sql b/internal/db/queries/wanted.sql index 49077412..173e582c 100644 --- a/internal/db/queries/wanted.sql +++ b/internal/db/queries/wanted.sql @@ -177,7 +177,10 @@ DELETE FROM wants WHERE id = $1; -- name: ListWants :many -- The Wanted page: open wants (all but missing ones, or only missing ones), --- or what was held most recently. +-- or the held ones. 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. 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, -- A request names an album before the catalogue has read it: the @@ -186,7 +189,8 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas COALESCE(g.secondary_types, '{}')::text[] AS secondary_types, COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date, u.username AS requested_by, - (SELECT ar.id FROM artists ar WHERE ar.mbid = w.artist_mbid) AS library_artist_id, + la.id AS library_artist_id, + COUNT(*) OVER () AS total, -- The search schedule's last look (#5521), and the download running. w.searched_at, w.search_outcome, w.search_considered, w.best_release, w.best_verdict, w.search_error, -- Its latest download, running or ended, and what the import made of @@ -199,6 +203,7 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas FROM wants w LEFT JOIN users u ON u.id = w.created_by LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid + LEFT JOIN LATERAL (SELECT ar.id, ar.sort_name FROM artists ar WHERE ar.mbid = w.artist_mbid LIMIT 1) la ON true 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 @@ -206,9 +211,26 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing' ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing' END - ORDER BY COALESCE(w.fulfilled_at, w.wanted_at) DESC, w.id + 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) + 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 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; + +-- name: ListLibraryAlbumTracks :many +-- A held or missing album's own tracks, for the Wanted view's track list; +-- missing says its file is gone. +SELECT title, disc_number, track_number, duration_ms, (missing_since IS NOT NULL)::bool AS missing + FROM tracks + WHERE album_id = $1 + ORDER BY disc_number NULLS FIRST, track_number NULLS LAST, title; + -- 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, diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 2877ca37..b70e78f4 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -41,7 +41,8 @@ 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) => ['adminWants', { state }] as const, + adminWants: (state: string, q: string = '') => ['adminWants', { state, q }] as const, + wantTracks: (id: string) => ['wantTracks', id] as const, adminFollows: () => ['adminFollows'] as const, adminFollow: (artistMbid: string) => ['adminFollow', artistMbid] as const, releaseProfiles: () => ['releaseProfiles'] as const, diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 957fc4a3..9e3950ff 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -821,6 +821,13 @@ export type LibraryListing = { listed_at: string | null; unlisted: number; unmat export type WantsPage = Page & { counts: WantCounts; library: LibraryListing }; +// An album's tracks in the Wanted view (#5572): the library's own for an +// album it holds (a lost file marked missing), else MusicBrainz's standard +// edition, named by edition; none when MusicBrainz lists no edition. +export type WantTrack = { disc: number; number: number; title: string; length_ms: number; missing: boolean }; + +export type WantTracks = { source: 'library' | 'musicbrainz' | 'none'; edition?: string; tracks: WantTrack[] }; + export type Follow = { artist_mbid: string; artist_name: string; diff --git a/web/src/lib/api/wanted.ts b/web/src/lib/api/wanted.ts index 147e5d28..c81f6f18 100644 --- a/web/src/lib/api/wanted.ts +++ b/web/src/lib/api/wanted.ts @@ -1,4 +1,4 @@ -import { createInfiniteQuery, createQuery } from '@tanstack/svelte-query'; +import { createInfiniteQuery, createQuery, keepPreviousData } from '@tanstack/svelte-query'; import { api } from './client'; import { qk } from './queries'; import type { @@ -14,6 +14,7 @@ import type { SearchResult, SearchSettings, WantState, + WantTracks, WantsPage } from './types'; @@ -21,8 +22,11 @@ import type { // artists it follows for new releases, and the release-type profiles that // say which kinds of release a follow wants. -export async function listWants(state: WantState, offset: number = 0): Promise { - return api.get(`/api/admin/wanted?state=${state}&offset=${offset}`); +// 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 { + const params = new URLSearchParams({ state, offset: String(offset) }); + if (q) params.set('q', q); + return api.get(`/api/admin/wanted?${params}`); } export function wantsNextOffset(last: WantsPage): number | undefined { @@ -31,16 +35,32 @@ export function wantsNextOffset(last: WantsPage): number | undefined { } // Loads as the page scrolls (rule 172), one tab at a time. -export function createWantsQuery(state: WantState) { +export function createWantsQuery(state: WantState, q: string = '') { return createInfiniteQuery({ - queryKey: qk.adminWants(state), - queryFn: ({ pageParam }) => listWants(state, pageParam as number), + queryKey: qk.adminWants(state, q), + queryFn: ({ pageParam }) => listWants(state, pageParam as number, q), initialPageParam: 0, getNextPageParam: wantsNextOffset, + // A new filter keeps the list it narrows on screen until its answer comes. + 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 { + return api.get(`/api/admin/wanted/${id}/tracks`); +} + +export function createWantTracksQuery(id: string) { + return createQuery({ + queryKey: qk.wantTracks(id), + queryFn: () => getWantTracks(id), + staleTime: 5 * 60_000 + }); +} + export async function removeWant(id: string): Promise { await api.del(`/api/admin/wanted/${id}`); } diff --git a/web/src/lib/components/wanted/WantList.svelte b/web/src/lib/components/wanted/WantList.svelte index 168f9fee..3771a890 100644 --- a/web/src/lib/components/wanted/WantList.svelte +++ b/web/src/lib/components/wanted/WantList.svelte @@ -1,5 +1,5 @@ + +
+ {#if tracks.isPending} +

Reading the tracks…

+ {:else if tracks.isError} +

+ {away ? "MusicBrainz can't be reached, so the tracks can't be shown." : "Couldn't load the tracks."} +

+ {:else if tracks.data.source === 'none' || list.length === 0} +

MusicBrainz lists no edition of this album, so there are no tracks to show.

+ {:else} + {#if tracks.data.source === 'musicbrainz'} +

+ MusicBrainz's standard edition{tracks.data.edition ? ` · ${tracks.data.edition}` : ''} +

+ {/if} +
    + {#each list as t, i (i)} +
  1. + {position(t)} + + {t.title} + + {#if t.missing}file missing{/if} + {#if t.length_ms > 0} + {formatDuration(t.length_ms / 1000)} + {/if} +
  2. + {/each} +
+ {/if} +