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"
|
||||
"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 ""
|
||||
|
||||
@@ -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.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)
|
||||
|
||||
Reference in New Issue
Block a user