feat: the Wanted view lists artists A–Z with their albums inside, each album's tracks a click away (M502 #5572)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m38s
release / integration (push) Successful in 5m35s
release / android (push) Successful in 5m59s
release / Build signed APK (releases and dev) (push) Successful in 6m10s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m38s
release / integration (push) Successful in 5m35s
release / android (push) Successful in 5m59s
release / Build signed APK (releases and dev) (push) Successful in 6m10s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
The list runs like the library: artists by the library's sort name, each artist's albums oldest first, grouped under the artist (linked into the library when it holds them). A filter narrows it to an artist or album by name, on the server, so it finds what has not been scrolled to. Tracks opens an album's track list: the library's own for an album it holds, a lost file marked, otherwise MusicBrainz's standard edition, named. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,12 +4,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"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/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
|
||||||
@@ -111,8 +113,9 @@ const unmatchedShown = 200
|
|||||||
|
|
||||||
const wantsPageSize = 50
|
const wantsPageSize = 50
|
||||||
|
|
||||||
// handleListWants implements GET /api/admin/wanted?state=wanted|missing|held&offset=.
|
// handleListWants implements GET /api/admin/wanted?state=wanted|missing|held&q=&offset=.
|
||||||
// wanted is everything open but missing files; held is newest first.
|
// 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) {
|
func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
|
||||||
state := r.URL.Query().Get("state")
|
state := r.URL.Query().Get("state")
|
||||||
if state == "" {
|
if state == "" {
|
||||||
@@ -127,7 +130,8 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
|
|||||||
offset = 0
|
offset = 0
|
||||||
}
|
}
|
||||||
q := dbq.New(h.pool)
|
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 {
|
if err != nil {
|
||||||
writeErrWithLog(w, h.logger, "admin wanted: list", apierror.Internal(err))
|
writeErrWithLog(w, h.logger, "admin wanted: list", apierror.Internal(err))
|
||||||
return
|
return
|
||||||
@@ -173,6 +177,12 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
|
|||||||
default:
|
default:
|
||||||
out.Total = counts.Wanted
|
out.Total = counts.Wanted
|
||||||
}
|
}
|
||||||
|
if filter != "" {
|
||||||
|
out.Total = 0
|
||||||
|
if len(rows) > 0 {
|
||||||
|
out.Total = rows[0].Total
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
v := wantView{
|
v := wantView{
|
||||||
ID: uuidToString(row.ID), ReleaseGroupMBID: row.ReleaseGroupMbid, Title: row.Title,
|
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)
|
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 {
|
func first4(date string) string {
|
||||||
if len(date) < 4 {
|
if len(date) < 4 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -257,6 +257,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
admin.Get("/acquisition/settings", h.handleGetSearchSettings)
|
admin.Get("/acquisition/settings", h.handleGetSearchSettings)
|
||||||
admin.Put("/acquisition/settings", h.handlePutSearchSettings)
|
admin.Put("/acquisition/settings", h.handlePutSearchSettings)
|
||||||
admin.Get("/wanted/{id}/candidates", h.handleListCandidates)
|
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}/search", h.handleSearchWant)
|
||||||
admin.Post("/wanted/{id}/grab", h.handleGrabCandidate)
|
admin.Post("/wanted/{id}/grab", h.handleGrabCandidate)
|
||||||
admin.Post("/wanted/{id}/block", h.handleBlockCandidate)
|
admin.Post("/wanted/{id}/block", h.handleBlockCandidate)
|
||||||
|
|||||||
@@ -273,6 +273,24 @@ func (q *Queries) GetReleaseTypeProfile(ctx context.Context, id pgtype.UUID) (Re
|
|||||||
return i, err
|
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
|
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
|
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
|
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
|
const listLibraryAlbums = `-- name: ListLibraryAlbums :execrows
|
||||||
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id, wanted_at, fulfilled_at)
|
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)
|
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(g.secondary_types, '{}')::text[] AS secondary_types,
|
||||||
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date,
|
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date,
|
||||||
u.username AS requested_by,
|
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.
|
-- 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,
|
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
|
-- 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
|
FROM wants w
|
||||||
LEFT JOIN users u ON u.id = w.created_by
|
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 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
|
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)
|
ORDER BY x.ended_at IS NULL DESC, x.grabbed_at DESC LIMIT 1)
|
||||||
WHERE CASE $1::text
|
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'
|
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
|
||||||
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
|
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
|
||||||
END
|
END
|
||||||
ORDER BY COALESCE(w.fulfilled_at, w.wanted_at) DESC, w.id
|
AND ($2::text = ''
|
||||||
LIMIT $3 OFFSET $2
|
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 {
|
type ListWantsParams struct {
|
||||||
State string
|
State string
|
||||||
|
Q string
|
||||||
Skip int32
|
Skip int32
|
||||||
MaxRows int32
|
MaxRows int32
|
||||||
}
|
}
|
||||||
@@ -845,6 +914,7 @@ type ListWantsRow struct {
|
|||||||
FirstReleaseDate string
|
FirstReleaseDate string
|
||||||
RequestedBy *string
|
RequestedBy *string
|
||||||
LibraryArtistID pgtype.UUID
|
LibraryArtistID pgtype.UUID
|
||||||
|
Total int64
|
||||||
SearchedAt pgtype.Timestamptz
|
SearchedAt pgtype.Timestamptz
|
||||||
SearchOutcome string
|
SearchOutcome string
|
||||||
SearchConsidered int32
|
SearchConsidered int32
|
||||||
@@ -867,9 +937,17 @@ type ListWantsRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The Wanted page: open wants (all but missing ones, or only missing ones),
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -892,6 +970,7 @@ func (q *Queries) ListWants(ctx context.Context, arg ListWantsParams) ([]ListWan
|
|||||||
&i.FirstReleaseDate,
|
&i.FirstReleaseDate,
|
||||||
&i.RequestedBy,
|
&i.RequestedBy,
|
||||||
&i.LibraryArtistID,
|
&i.LibraryArtistID,
|
||||||
|
&i.Total,
|
||||||
&i.SearchedAt,
|
&i.SearchedAt,
|
||||||
&i.SearchOutcome,
|
&i.SearchOutcome,
|
||||||
&i.SearchConsidered,
|
&i.SearchConsidered,
|
||||||
|
|||||||
@@ -177,7 +177,10 @@ DELETE FROM wants WHERE id = $1;
|
|||||||
|
|
||||||
-- name: ListWants :many
|
-- name: ListWants :many
|
||||||
-- The Wanted page: open wants (all but missing ones, or only missing ones),
|
-- 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,
|
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,
|
w.album_id, w.wanted_at, w.fulfilled_at,
|
||||||
-- A request names an album before the catalogue has read it: the
|
-- 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(g.secondary_types, '{}')::text[] AS secondary_types,
|
||||||
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date,
|
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date,
|
||||||
u.username AS requested_by,
|
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.
|
-- 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,
|
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
|
-- 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
|
FROM wants w
|
||||||
LEFT JOIN users u ON u.id = w.created_by
|
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 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
|
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)
|
ORDER BY x.ended_at IS NULL DESC, x.grabbed_at DESC LIMIT 1)
|
||||||
WHERE CASE sqlc.arg(state)::text
|
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'
|
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
|
||||||
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
|
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
|
||||||
END
|
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);
|
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
|
-- name: CountWants :one
|
||||||
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
|
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 NULL AND reason = 'missing')::bigint AS missing,
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ export const qk = {
|
|||||||
['adminRequests', { status: status ?? 'all' }] as const,
|
['adminRequests', { status: status ?? 'all' }] as const,
|
||||||
// Wanted list (M502 #5520). Wants sit under ['adminWants'] so one prefix
|
// Wanted list (M502 #5520). Wants sit under ['adminWants'] so one prefix
|
||||||
// invalidation refreshes every tab and its counts.
|
// 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,
|
adminFollows: () => ['adminFollows'] as const,
|
||||||
adminFollow: (artistMbid: string) => ['adminFollow', artistMbid] as const,
|
adminFollow: (artistMbid: string) => ['adminFollow', artistMbid] as const,
|
||||||
releaseProfiles: () => ['releaseProfiles'] as const,
|
releaseProfiles: () => ['releaseProfiles'] as const,
|
||||||
|
|||||||
@@ -821,6 +821,13 @@ export type LibraryListing = { listed_at: string | null; unlisted: number; unmat
|
|||||||
|
|
||||||
export type WantsPage = Page<Want> & { counts: WantCounts; library: LibraryListing };
|
export type WantsPage = Page<Want> & { 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 = {
|
export type Follow = {
|
||||||
artist_mbid: string;
|
artist_mbid: string;
|
||||||
artist_name: string;
|
artist_name: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createInfiniteQuery, createQuery } from '@tanstack/svelte-query';
|
import { createInfiniteQuery, createQuery, keepPreviousData } from '@tanstack/svelte-query';
|
||||||
import { api } from './client';
|
import { api } from './client';
|
||||||
import { qk } from './queries';
|
import { qk } from './queries';
|
||||||
import type {
|
import type {
|
||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
SearchResult,
|
SearchResult,
|
||||||
SearchSettings,
|
SearchSettings,
|
||||||
WantState,
|
WantState,
|
||||||
|
WantTracks,
|
||||||
WantsPage
|
WantsPage
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -21,8 +22,11 @@ import type {
|
|||||||
// artists it follows for new releases, and the release-type profiles that
|
// artists it follows for new releases, and the release-type profiles that
|
||||||
// say which kinds of release a follow wants.
|
// say which kinds of release a follow wants.
|
||||||
|
|
||||||
export async function listWants(state: WantState, offset: number = 0): Promise<WantsPage> {
|
// q narrows the list to an artist or album whose name holds it (#5572).
|
||||||
return api.get<WantsPage>(`/api/admin/wanted?state=${state}&offset=${offset}`);
|
export async function listWants(state: WantState, offset: number = 0, q: string = ''): Promise<WantsPage> {
|
||||||
|
const params = new URLSearchParams({ state, offset: String(offset) });
|
||||||
|
if (q) params.set('q', q);
|
||||||
|
return api.get<WantsPage>(`/api/admin/wanted?${params}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wantsNextOffset(last: WantsPage): number | undefined {
|
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.
|
// 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({
|
return createInfiniteQuery({
|
||||||
queryKey: qk.adminWants(state),
|
queryKey: qk.adminWants(state, q),
|
||||||
queryFn: ({ pageParam }) => listWants(state, pageParam as number),
|
queryFn: ({ pageParam }) => listWants(state, pageParam as number, q),
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
getNextPageParam: wantsNextOffset,
|
getNextPageParam: wantsNextOffset,
|
||||||
|
// A new filter keeps the list it narrows on screen until its answer comes.
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
staleTime: 30_000
|
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> {
|
||||||
|
return api.get<WantTracks>(`/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<void> {
|
export async function removeWant(id: string): Promise<void> {
|
||||||
await api.del(`/api/admin/wanted/${id}`);
|
await api.del(`/api/admin/wanted/${id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Album, ChevronDown, ListChecks, Search, X } from 'lucide-svelte';
|
import { Album, ChevronDown, ListChecks, ListMusic, Search, X } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { createWantsQuery, removeWant, searchWant } from '#lib/api/wanted.js';
|
import { createWantsQuery, removeWant, searchWant } from '#lib/api/wanted.js';
|
||||||
import { qk } from '#lib/api/queries.js';
|
import { qk } from '#lib/api/queries.js';
|
||||||
@@ -7,7 +7,9 @@
|
|||||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||||
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
||||||
|
import QuickFilter from '#lib/components/QuickFilter.svelte';
|
||||||
import WantCandidates from '#lib/components/WantCandidates.svelte';
|
import WantCandidates from '#lib/components/WantCandidates.svelte';
|
||||||
|
import WantTracks from './WantTracks.svelte';
|
||||||
import type { SearchResult, Want, WantState } from '#lib/api/types.js';
|
import type { SearchResult, Want, WantState } from '#lib/api/types.js';
|
||||||
|
|
||||||
// One tab of the Wanted view (M502 #5520–#5522): the albums still wanted,
|
// One tab of the Wanted view (M502 #5520–#5522): the albums still wanted,
|
||||||
@@ -17,19 +19,41 @@
|
|||||||
// library's albums were put on the list as held once (#5567), each matched to
|
// 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
|
// 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.
|
// fetched back if they go. The ones no match was found for are named here.
|
||||||
|
//
|
||||||
|
// 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 }: { list: WantState } = $props();
|
||||||
|
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const wantsStore = $derived(createWantsQuery(list));
|
let filter = $state('');
|
||||||
|
const q = $derived(filter.trim());
|
||||||
|
const wantsStore = $derived(createWantsQuery(list, q));
|
||||||
const wants = $derived($wantsStore);
|
const wants = $derived($wantsStore);
|
||||||
const items = $derived((wants.data?.pages ?? []).flatMap((p) => p.items) as Want[]);
|
const items = $derived((wants.data?.pages ?? []).flatMap((p) => p.items) as Want[]);
|
||||||
const library = $derived(wants.data?.pages[0]?.library);
|
const library = $derived(wants.data?.pages[0]?.library);
|
||||||
|
|
||||||
|
// 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[] };
|
||||||
|
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] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
let busy = $state<string | null>(null);
|
let busy = $state<string | null>(null);
|
||||||
let showUnmatched = $state(false);
|
let showUnmatched = $state(false);
|
||||||
let searching = $state<string | null>(null);
|
let searching = $state<string | null>(null);
|
||||||
let open = $state<string | null>(null);
|
let open = $state<string | null>(null);
|
||||||
|
let shown = $state<string | null>(null);
|
||||||
|
|
||||||
async function onRemove(w: Want) {
|
async function onRemove(w: Want) {
|
||||||
busy = w.id;
|
busy = w.id;
|
||||||
@@ -150,6 +174,8 @@
|
|||||||
return 'Its files went missing';
|
return 'Its files went missing';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const albums = (n: number) => (n === 1 ? '1 album' : `${n} albums`);
|
||||||
|
|
||||||
function kind(w: Want): string {
|
function kind(w: Want): string {
|
||||||
const parts = [w.primary_type, ...w.secondary_types].filter(Boolean);
|
const parts = [w.primary_type, ...w.secondary_types].filter(Boolean);
|
||||||
if (w.year) parts.push(w.year);
|
if (w.year) parts.push(w.year);
|
||||||
@@ -207,10 +233,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if !wants.isPending && (items.length > 0 || q)}
|
||||||
|
<div class="mb-3">
|
||||||
|
<QuickFilter bind:value={filter} placeholder="Filter artists and albums" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if wants.isPending}
|
{#if wants.isPending}
|
||||||
<p class="text-text-secondary">Reading the wanted list…</p>
|
<p class="text-text-secondary">Reading the wanted list…</p>
|
||||||
{:else if wants.isError && !wants.isFetchNextPageError}
|
{: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 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>.
|
||||||
|
</p>
|
||||||
{:else if items.length === 0}
|
{:else if items.length === 0}
|
||||||
<div class="rounded-lg border border-border bg-surface p-6 text-center">
|
<div class="rounded-lg border border-border bg-surface p-6 text-center">
|
||||||
<ListChecks size={28} strokeWidth={1} class="mx-auto text-text-muted" />
|
<ListChecks size={28} strokeWidth={1} class="mx-auto text-text-muted" />
|
||||||
@@ -229,100 +265,113 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
<div class="space-y-4">
|
||||||
{#each items as w (w.id)}
|
{#each sections as a, i (a.key + ':' + i)}
|
||||||
<li class="space-y-2 p-3" data-testid="want-row">
|
<section data-testid="want-artist" aria-label={a.name}>
|
||||||
<div class="flex items-center gap-3">
|
<h2 class="mb-1.5 flex items-baseline gap-2 px-1">
|
||||||
<div class="relative flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md bg-surface-hover" aria-hidden="true">
|
<span class="truncate text-sm font-medium text-text-primary">
|
||||||
<Album size={22} strokeWidth={1} class="text-text-muted" />
|
{#if a.artistId}
|
||||||
{#if w.image_url}
|
<a href="/artists/{a.artistId}" class="hover:underline">{a.name}</a>
|
||||||
<img src={w.image_url} alt="" class="absolute inset-0 h-full w-full object-cover" loading="lazy" onerror={hideBroken} />
|
{:else}
|
||||||
|
{a.name}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</span>
|
||||||
<div class="min-w-0 flex-1 space-y-0.5">
|
<span class="shrink-0 text-xs text-text-muted">{albums(a.wants.length)}</span>
|
||||||
<div class="truncate text-sm font-medium text-text-primary">
|
</h2>
|
||||||
{#if w.album_id && w.held_at}
|
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||||
<a href="/albums/{w.album_id}" class="hover:underline">{w.title}</a>
|
{#each a.wants as w (w.id)}
|
||||||
{:else}
|
<li class="space-y-2 p-3" data-testid="want-row">
|
||||||
{w.title}
|
<div class="flex items-center gap-3">
|
||||||
{/if}
|
<div class="relative flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md bg-surface-hover" aria-hidden="true">
|
||||||
</div>
|
<Album size={22} strokeWidth={1} class="text-text-muted" />
|
||||||
<div class="truncate text-xs text-text-secondary">
|
{#if w.image_url}
|
||||||
{#if w.artist_id}
|
<img src={w.image_url} alt="" class="absolute inset-0 h-full w-full object-cover" loading="lazy" onerror={hideBroken} />
|
||||||
<a href="/artists/{w.artist_id}" class="hover:text-text-primary hover:underline">{w.artist_name}</a>
|
{/if}
|
||||||
{:else}
|
</div>
|
||||||
{w.artist_name}
|
<div class="min-w-0 flex-1 space-y-0.5">
|
||||||
{/if}
|
<div class="truncate text-sm font-medium text-text-primary">
|
||||||
{#if kind(w)}<span class="text-text-muted"> · {kind(w)}</span>{/if}
|
{#if w.album_id && w.held_at}
|
||||||
</div>
|
<a href="/albums/{w.album_id}" class="hover:underline">{w.title}</a>
|
||||||
<div class="text-xs text-text-muted" data-testid="want-why">
|
{:else}
|
||||||
{why(w)} · {w.held_at ? `held ${relativeTime(w.held_at)}` : `wanted ${relativeTime(w.wanted_at)}`}
|
{w.title}
|
||||||
</div>
|
{/if}
|
||||||
{#if !w.held_at}
|
{#if kind(w)}<span class="text-xs font-normal text-text-muted"> · {kind(w)}</span>{/if}
|
||||||
<div
|
</div>
|
||||||
class="text-xs {attention(w) ? 'text-text-primary' : 'truncate text-text-secondary'}"
|
<div class="text-xs text-text-muted" data-testid="want-why">
|
||||||
data-testid="want-search"
|
{why(w)} · {w.held_at ? `held ${relativeTime(w.held_at)}` : `wanted ${relativeTime(w.wanted_at)}`}
|
||||||
>
|
</div>
|
||||||
{status(w)}
|
{#if !w.held_at}
|
||||||
{#if attention(w)}
|
<div
|
||||||
<a href="/wanted/activity" class="ml-1 text-accent-fg hover:underline">Decide on Activity</a>
|
class="text-xs {attention(w) ? 'text-text-primary' : 'truncate text-text-secondary'}"
|
||||||
{/if}
|
data-testid="want-search"
|
||||||
|
>
|
||||||
|
{status(w)}
|
||||||
|
{#if attention(w)}
|
||||||
|
<a href="/wanted/activity" class="ml-1 text-accent-fg hover:underline">Decide on Activity</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if arrived(w)}
|
||||||
|
<div class="truncate text-xs text-text-secondary" data-testid="want-arrived">{arrived(w)}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (shown = shown === w.id ? null : w.id)}
|
||||||
|
aria-expanded={shown === w.id}
|
||||||
|
aria-label="Tracks of {w.title}"
|
||||||
|
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<ListMusic size={12} strokeWidth={2} /> Tracks
|
||||||
|
<ChevronDown size={12} strokeWidth={2} class={shown === w.id ? 'rotate-180' : ''} />
|
||||||
|
</button>
|
||||||
|
{#if !w.held_at}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => onSearch(w)}
|
||||||
|
disabled={searching !== null || !!w.grab?.live}
|
||||||
|
aria-label="Search for {w.title} now"
|
||||||
|
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Search size={12} strokeWidth={2} /> {searching === w.id ? 'Searching…' : 'Search'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (open = open === w.id ? null : w.id)}
|
||||||
|
aria-expanded={open === w.id}
|
||||||
|
aria-label="Releases found for {w.title}"
|
||||||
|
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary"
|
||||||
|
>
|
||||||
|
Releases <ChevronDown size={12} strokeWidth={2} class={open === w.id ? 'rotate-180' : ''} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => onRemove(w)}
|
||||||
|
disabled={busy === w.id}
|
||||||
|
aria-label={w.held_at ? `Take ${w.title} off the list` : `Stop wanting ${w.title}`}
|
||||||
|
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<X size={12} strokeWidth={2} /> Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if arrived(w)}
|
{#if shown === w.id}
|
||||||
<div class="truncate text-xs text-text-secondary" data-testid="want-arrived">{arrived(w)}</div>
|
<div class="sm:pl-[4.25rem]">
|
||||||
{/if}
|
<WantTracks wantId={w.id} />
|
||||||
</div>
|
</div>
|
||||||
{#if w.held_at}
|
{/if}
|
||||||
<button
|
{#if open === w.id && !w.held_at}
|
||||||
type="button"
|
<div class="sm:pl-[4.25rem]">
|
||||||
onclick={() => onRemove(w)}
|
<WantCandidates wantId={w.id} title={w.title} />
|
||||||
disabled={busy === w.id}
|
</div>
|
||||||
aria-label="Take {w.title} off the list"
|
{/if}
|
||||||
class="inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
</li>
|
||||||
>
|
{/each}
|
||||||
<X size={12} strokeWidth={2} /> Remove
|
</ul>
|
||||||
</button>
|
</section>
|
||||||
{/if}
|
|
||||||
{#if !w.held_at}
|
|
||||||
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => onSearch(w)}
|
|
||||||
disabled={searching !== null || !!w.grab?.live}
|
|
||||||
aria-label="Search for {w.title} now"
|
|
||||||
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Search size={12} strokeWidth={2} /> {searching === w.id ? 'Searching…' : 'Search'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (open = open === w.id ? null : w.id)}
|
|
||||||
aria-expanded={open === w.id}
|
|
||||||
aria-label="Releases found for {w.title}"
|
|
||||||
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary"
|
|
||||||
>
|
|
||||||
Releases <ChevronDown size={12} strokeWidth={2} class={open === w.id ? 'rotate-180' : ''} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => onRemove(w)}
|
|
||||||
disabled={busy === w.id}
|
|
||||||
aria-label="Stop wanting {w.title}"
|
|
||||||
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<X size={12} strokeWidth={2} /> Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if open === w.id && !w.held_at}
|
|
||||||
<div class="sm:pl-[4.25rem]">
|
|
||||||
<WantCandidates wantId={w.id} title={w.title} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</div>
|
||||||
|
|
||||||
<ListContinuation
|
<ListContinuation
|
||||||
hasMore={!!wants.hasNextPage}
|
hasMore={!!wants.hasNextPage}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ vi.mock('#lib/api/wanted.js', () => ({
|
|||||||
outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', narrowed: []
|
outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', narrowed: []
|
||||||
}),
|
}),
|
||||||
createCandidatesQuery: vi.fn(),
|
createCandidatesQuery: vi.fn(),
|
||||||
|
createWantTracksQuery: vi.fn(),
|
||||||
grabCandidate: vi.fn(),
|
grabCandidate: vi.fn(),
|
||||||
blockCandidate: vi.fn(),
|
blockCandidate: vi.fn(),
|
||||||
unblockRelease: vi.fn()
|
unblockRelease: vi.fn()
|
||||||
@@ -18,8 +19,8 @@ vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
|
|||||||
|
|
||||||
import WantList from './WantList.svelte';
|
import WantList from './WantList.svelte';
|
||||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
import { createCandidatesQuery, createWantsQuery, removeWant, searchWant } from '#lib/api/wanted.js';
|
import { createCandidatesQuery, createWantTracksQuery, createWantsQuery, removeWant, searchWant } from '#lib/api/wanted.js';
|
||||||
import type { WantState } from '#lib/api/types.js';
|
import type { WantState, WantTracks } from '#lib/api/types.js';
|
||||||
|
|
||||||
const DAY = 24 * 3_600_000;
|
const DAY = 24 * 3_600_000;
|
||||||
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
|
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
|
||||||
@@ -81,7 +82,15 @@ afterEach(() => vi.clearAllMocks());
|
|||||||
function setup(
|
function setup(
|
||||||
items: Want[] = [want('w1'), want('w2', { reason: 'request', requested_by: 'sam', title: 'Parklife' })],
|
items: Want[] = [want('w1'), want('w2', { reason: 'request', requested_by: 'sam', title: 'Parklife' })],
|
||||||
state: WantState = 'wanted',
|
state: WantState = 'wanted',
|
||||||
library?: LibraryListing
|
library?: LibraryListing,
|
||||||
|
tracks: WantTracks = {
|
||||||
|
source: 'musicbrainz',
|
||||||
|
edition: '2023 · CD · GB',
|
||||||
|
tracks: [
|
||||||
|
{ disc: 1, number: 1, title: 'The Ballad', length_ms: 214000, missing: false },
|
||||||
|
{ disc: 1, number: 2, title: 'St. Charles Square', length_ms: 3_725_000, missing: false }
|
||||||
|
]
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
vi.mocked(createWantsQuery).mockReturnValue(
|
vi.mocked(createWantsQuery).mockReturnValue(
|
||||||
mockInfiniteQuery({ pages: [page(items, library)] }) as unknown as ReturnType<typeof createWantsQuery>
|
mockInfiniteQuery({ pages: [page(items, library)] }) as unknown as ReturnType<typeof createWantsQuery>
|
||||||
@@ -89,13 +98,16 @@ function setup(
|
|||||||
vi.mocked(createCandidatesQuery).mockReturnValue(
|
vi.mocked(createCandidatesQuery).mockReturnValue(
|
||||||
mockQuery({ data: [] }) as unknown as ReturnType<typeof createCandidatesQuery>
|
mockQuery({ data: [] }) as unknown as ReturnType<typeof createCandidatesQuery>
|
||||||
);
|
);
|
||||||
|
vi.mocked(createWantTracksQuery).mockReturnValue(
|
||||||
|
mockQuery({ data: tracks }) as unknown as ReturnType<typeof createWantTracksQuery>
|
||||||
|
);
|
||||||
return render(WantList, { list: state });
|
return render(WantList, { list: state });
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('WantList', () => {
|
describe('WantList', () => {
|
||||||
test('asks for its own state', () => {
|
test('asks for its own state', () => {
|
||||||
setup([], 'missing');
|
setup([], 'missing');
|
||||||
expect(createWantsQuery).toHaveBeenCalledWith('missing');
|
expect(createWantsQuery).toHaveBeenCalledWith('missing', '');
|
||||||
expect(screen.getByText(/no missing albums are wanted back/i)).toBeTruthy();
|
expect(screen.getByText(/no missing albums are wanted back/i)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -227,4 +239,56 @@ describe('WantList', () => {
|
|||||||
expect(screen.getByTestId('want-candidates')).toBeTruthy();
|
expect(screen.getByTestId('want-candidates')).toBeTruthy();
|
||||||
expect(createCandidatesQuery).toHaveBeenCalledWith('w2');
|
expect(createCandidatesQuery).toHaveBeenCalledWith('w2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('albums sit under their artist, as in the library', () => {
|
||||||
|
setup([
|
||||||
|
want('w1', { artist_mbid: 'blur', artist_name: 'Blur', artist_id: 'art-1', title: 'Parklife' }),
|
||||||
|
want('w2', { artist_mbid: 'blur', artist_name: 'Blur', artist_id: 'art-1', title: '13' }),
|
||||||
|
want('w3', { artist_mbid: 'camel', artist_name: 'Camel', artist_id: undefined, title: 'Moonmadness' })
|
||||||
|
]);
|
||||||
|
const sections = screen.getAllByTestId('want-artist');
|
||||||
|
expect(sections.map((s) => s.getAttribute('aria-label'))).toEqual(['Blur', 'Camel']);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Tracks shows the album\'s tracks, with the edition they come from', async () => {
|
||||||
|
setup();
|
||||||
|
expect(screen.queryByTestId('want-tracks')).toBeNull();
|
||||||
|
const toggle = screen.getByRole('button', { name: /tracks of parklife/i });
|
||||||
|
await fireEvent.click(toggle);
|
||||||
|
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||||
|
expect(createWantTracksQuery).toHaveBeenCalledWith('w2');
|
||||||
|
const panel = screen.getByTestId('want-tracks');
|
||||||
|
expect(panel.textContent).toMatch(/MusicBrainz's standard edition\s+·\s+2023 · CD · GB/);
|
||||||
|
const rows = screen.getAllByTestId('want-track').map((e) => e.textContent?.replace(/\s+/g, ' ').trim());
|
||||||
|
expect(rows).toEqual(['1 The Ballad 3:34', '2 St. Charles Square 1:02:05']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a held album\'s tracks are its own, a lost file marked', async () => {
|
||||||
|
setup([want('w1', { held_at: ago(DAY), album_id: 'al-1' })], 'held', undefined, {
|
||||||
|
source: 'library',
|
||||||
|
tracks: [
|
||||||
|
{ disc: 1, number: 1, title: 'The Ballad', length_ms: 214000, missing: true },
|
||||||
|
{ disc: 2, number: 1, title: 'Bonus', length_ms: 0, missing: false }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /tracks of the ballad of darren/i }));
|
||||||
|
const panel = screen.getByTestId('want-tracks');
|
||||||
|
expect(panel.textContent).not.toMatch(/MusicBrainz/);
|
||||||
|
const rows = screen.getAllByTestId('want-track').map((e) => e.textContent?.replace(/\s+/g, ' ').trim());
|
||||||
|
expect(rows).toEqual(['1-1 The Ballad file missing 3:34', '2-1 Bonus']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the filter asks the server for the artists and albums it names', async () => {
|
||||||
|
setup();
|
||||||
|
await fireEvent.input(screen.getByRole('searchbox', { name: /filter artists and albums/i }), {
|
||||||
|
target: { value: ' park ' }
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(createWantsQuery).toHaveBeenLastCalledWith('wanted', 'park'));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createWantTracksQuery } from '#lib/api/wanted.js';
|
||||||
|
import { formatDuration } from '#lib/media/duration.js';
|
||||||
|
import { errCode } from '#lib/api/errors.js';
|
||||||
|
import type { WantTrack } from '#lib/api/types.js';
|
||||||
|
|
||||||
|
// An album's tracks under its row in the Wanted view (#5572): the
|
||||||
|
// library's own for an album it holds, a lost file marked, otherwise
|
||||||
|
// MusicBrainz's standard edition of what is wanted.
|
||||||
|
|
||||||
|
let { wantId }: { wantId: string } = $props();
|
||||||
|
|
||||||
|
const tracksStore = $derived(createWantTracksQuery(wantId));
|
||||||
|
const tracks = $derived($tracksStore);
|
||||||
|
const list = $derived(tracks.data?.tracks ?? []);
|
||||||
|
const discs = $derived(list.some((t) => t.disc > 1));
|
||||||
|
const away = $derived(tracks.isError && errCode(tracks.error) === 'musicbrainz_unreachable');
|
||||||
|
|
||||||
|
const position = (t: WantTrack) => (discs ? `${t.disc}-${t.number}` : String(t.number || ''));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="text-xs" data-testid="want-tracks">
|
||||||
|
{#if tracks.isPending}
|
||||||
|
<p class="text-text-secondary">Reading the tracks…</p>
|
||||||
|
{:else if tracks.isError}
|
||||||
|
<p class="text-text-secondary">
|
||||||
|
{away ? "MusicBrainz can't be reached, so the tracks can't be shown." : "Couldn't load the tracks."}
|
||||||
|
</p>
|
||||||
|
{:else if tracks.data.source === 'none' || list.length === 0}
|
||||||
|
<p class="text-text-secondary">MusicBrainz lists no edition of this album, so there are no tracks to show.</p>
|
||||||
|
{:else}
|
||||||
|
{#if tracks.data.source === 'musicbrainz'}
|
||||||
|
<p class="mb-1 text-text-muted">
|
||||||
|
MusicBrainz's standard edition{tracks.data.edition ? ` · ${tracks.data.edition}` : ''}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<ol class="space-y-0.5">
|
||||||
|
{#each list as t, i (i)}
|
||||||
|
<li class="flex items-baseline gap-2" data-testid="want-track">
|
||||||
|
<span class="w-8 shrink-0 text-right tabular-nums text-text-muted">{position(t)}</span>
|
||||||
|
<span class="min-w-0 flex-1 truncate {t.missing ? 'text-text-muted line-through' : 'text-text-primary'}">
|
||||||
|
{t.title}
|
||||||
|
</span>
|
||||||
|
{#if t.missing}<span class="shrink-0 text-text-secondary">file missing</span>{/if}
|
||||||
|
{#if t.length_ms > 0}
|
||||||
|
<span class="shrink-0 tabular-nums text-text-muted">{formatDuration(t.length_ms / 1000)}</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user