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

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:
2026-10-10 18:43:32 -04:00
co-authored by Claude Opus 5.5
parent ca57133c8a
commit a4fbe44bd5
11 changed files with 753 additions and 115 deletions
+133 -3
View File
@@ -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 ""