Files
minstrel/internal/api/admin_wanted.go
T
bvandeusenandClaude Opus 5.5 ebbc49b902
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 15s
release / go (push) Failing after 1m16s
release / integration (push) Failing after 4m49s
release / android (push) Successful in 4m59s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
feat: import with verification, and a Wanted view of its own (M502 #5522)
A finished download is checked against MusicBrainz before anything is
filed: the files are paired with an edition's tracks by position or path,
and AcoustID, tag ids, lengths and titles say whether it is the album.
What is the album is copied into the library as
Artist/Album (Year)/Artist - Album - NN - Title, tagged with Picard's
MusicBrainz ids, and scanned; the download keeps seeding. What is not is
refused: set aside, removed from the client, and the album searched for
again. A download the check cannot settle waits for a person, who imports
it anyway or refuses it. Tracks the library already holds are skipped.

The wanted list leaves Admin for a top-level Wanted view, admin-only and
web-only, with tabs for Wanted, Activity (downloads and their imports,
the ones needing a person first), Missing files, Held, Following (with the
release-type profiles, moved off Integrations) and Blocked. The search
settings gain "File new music into" when the library has several folders.

The search pass logs why each failed search failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:36:21 -04:00

459 lines
15 KiB
Go

package api
import (
"errors"
"net/http"
"strconv"
"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/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// The wanted list's admin surface (M502 #5520): wants, follows and
// release-type profiles.
// wantView is one row of GET /api/admin/wanted.
type wantView struct {
ID string `json:"id"`
ReleaseGroupMBID string `json:"release_group_mbid"`
Title string `json:"title"`
ArtistMBID string `json:"artist_mbid"`
ArtistName string `json:"artist_name"`
ArtistID string `json:"artist_id,omitempty"`
PrimaryType string `json:"primary_type"`
SecondaryTypes []string `json:"secondary_types"`
Year string `json:"year"`
Reason string `json:"reason"`
RequestedBy string `json:"requested_by,omitempty"`
AlbumID string `json:"album_id,omitempty"`
WantedAt string `json:"wanted_at"`
HeldAt *string `json:"held_at"`
ImageURL string `json:"image_url"`
// Search is the schedule's last look; Grab the download running (#5521).
Search *wantSearchView `json:"search"`
Grab *wantGrabView `json:"grab"`
}
type wantSearchView struct {
SearchedAt string `json:"searched_at"`
Outcome string `json:"outcome"`
Considered int32 `json:"considered"`
BestRelease string `json:"best_release"`
BestVerdict string `json:"best_verdict"`
Error string `json:"error"`
}
// wantGrabView is the want's latest download, running or ended, and what
// its import made of it (#5522).
type wantGrabView struct {
ID string `json:"id"`
Name string `json:"name"`
GrabbedAt string `json:"grabbed_at"`
Origin string `json:"origin"`
Quality string `json:"quality"`
Live bool `json:"live"`
EndReason string `json:"end_reason"`
Progress float32 `json:"progress"`
ImportState string `json:"import_state"`
ImportReason string `json:"import_reason"`
ImportedAt *string `json:"imported_at"`
ImportedFiles int32 `json:"imported_files"`
}
// wantCounts sizes each tab of the Wanted page.
type wantCounts struct {
Wanted int64 `json:"wanted"`
Missing int64 `json:"missing"`
Held int64 `json:"held"`
Following int64 `json:"following"`
// Activity: downloads running; Attention: of those, the ones waiting on
// a person; Blocked: releases set aside (#5522).
Activity int64 `json:"activity"`
Attention int64 `json:"attention"`
Blocked int64 `json:"blocked"`
}
type wantsPage struct {
Items []wantView `json:"items"`
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Counts wantCounts `json:"counts"`
}
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.
func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
if state == "" {
state = "wanted"
}
if state != "wanted" && state != "missing" && state != "held" {
writeErr(w, apierror.BadRequest("bad_state", "state must be wanted, missing or held"))
return
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
q := dbq.New(h.pool)
rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: state, Skip: int32(offset), MaxRows: wantsPageSize})
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: list", apierror.Internal(err))
return
}
counts, err := q.CountWants(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: count", apierror.Internal(err))
return
}
out := wantsPage{
Items: make([]wantView, 0, len(rows)), Limit: wantsPageSize, Offset: offset,
Counts: wantCounts{Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held, Following: counts.Following,
Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked},
}
switch state {
case "missing":
out.Total = counts.Missing
case "held":
out.Total = counts.Held
default:
out.Total = counts.Wanted
}
for _, row := range rows {
v := wantView{
ID: uuidToString(row.ID), ReleaseGroupMBID: row.ReleaseGroupMbid, Title: row.Title,
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName,
PrimaryType: row.PrimaryType, SecondaryTypes: row.SecondaryTypes,
Year: first4(row.FirstReleaseDate), Reason: row.Reason,
WantedAt: formatTimestamp(row.WantedAt),
ImageURL: groupArtURL(musicbrainz.ReleaseGroup{MBID: row.ReleaseGroupMbid, Title: row.Title, ArtistCredit: row.ArtistName}),
}
if row.LibraryArtistID.Valid {
v.ArtistID = uuidToString(row.LibraryArtistID)
}
if row.AlbumID.Valid {
v.AlbumID = uuidToString(row.AlbumID)
}
if row.RequestedBy != nil {
v.RequestedBy = *row.RequestedBy
}
if row.FulfilledAt.Valid {
held := formatTimestamp(row.FulfilledAt)
v.HeldAt = &held
}
if v.SecondaryTypes == nil {
v.SecondaryTypes = []string{}
}
if row.SearchedAt.Valid {
v.Search = &wantSearchView{
SearchedAt: formatTimestamp(row.SearchedAt), Outcome: row.SearchOutcome,
Considered: row.SearchConsidered, BestRelease: row.BestRelease,
BestVerdict: row.BestVerdict, Error: row.SearchError,
}
}
if row.GrabName != nil {
v.Grab = &wantGrabView{ID: uuidToString(row.GrabID), Name: *row.GrabName,
GrabbedAt: formatTimestamp(row.GrabbedAt), Live: !row.GrabEndedAt.Valid,
Origin: deref(row.GrabOrigin), Quality: deref(row.GrabQuality), EndReason: deref(row.GrabEndReason),
ImportState: deref(row.GrabImportState), ImportReason: deref(row.GrabImportReason)}
if row.GrabProgress != nil {
v.Grab.Progress = *row.GrabProgress
}
if row.GrabImportedFiles != nil {
v.Grab.ImportedFiles = *row.GrabImportedFiles
}
if row.GrabImportedAt.Valid {
at := formatTimestamp(row.GrabImportedAt)
v.Grab.ImportedAt = &at
}
}
out.Items = append(out.Items, v)
}
writeJSON(w, http.StatusOK, out)
}
func first4(date string) string {
if len(date) < 4 {
return ""
}
return date[:4]
}
// handleRemoveWant implements DELETE /api/admin/wanted/{id}.
func (h *handlers) handleRemoveWant(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
removed, err := h.wanted.RemoveWant(r.Context(), id)
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: remove", apierror.Internal(err))
return
}
if !removed {
writeErr(w, apierror.NotFound("want"))
return
}
w.WriteHeader(http.StatusNoContent)
}
// followView is a followed artist.
type followView struct {
ArtistMBID string `json:"artist_mbid"`
ArtistName string `json:"artist_name"`
ArtistID string `json:"artist_id,omitempty"`
ProfileID string `json:"profile_id"`
ProfileName string `json:"profile_name"`
FollowedAt string `json:"followed_at"`
CheckedAt *string `json:"checked_at"`
OpenWants int64 `json:"open_wants"`
ImageURL string `json:"image_url"`
}
// handleListFollows implements GET /api/admin/follows.
func (h *handlers) handleListFollows(w http.ResponseWriter, r *http.Request) {
rows, err := dbq.New(h.pool).ListFollows(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin follows: list", apierror.Internal(err))
return
}
out := make([]followView, 0, len(rows))
for _, f := range rows {
v := followView{
ArtistMBID: f.ArtistMbid, ArtistName: f.ArtistName,
ProfileID: uuidToString(f.ProfileID), ProfileName: f.ProfileName,
FollowedAt: formatTimestamp(f.FollowedAt), OpenWants: f.OpenWants,
ImageURL: artistArtURL(f.ArtistMbid, f.ArtistName),
}
if f.ArtistID.Valid {
v.ArtistID = uuidToString(f.ArtistID)
}
if f.CheckedAt.Valid {
checked := formatTimestamp(f.CheckedAt)
v.CheckedAt = &checked
}
out = append(out, v)
}
writeJSON(w, http.StatusOK, out)
}
// followStateView answers whether one artist is followed, for the control on
// an artist page.
type followStateView struct {
Following bool `json:"following"`
ProfileID string `json:"profile_id,omitempty"`
ProfileName string `json:"profile_name,omitempty"`
}
// handleGetFollow implements GET /api/admin/follows/{mbid}.
func (h *handlers) handleGetFollow(w http.ResponseWriter, r *http.Request) {
f, err := dbq.New(h.pool).GetFollow(r.Context(), chi.URLParam(r, "mbid"))
if errors.Is(err, pgx.ErrNoRows) {
writeJSON(w, http.StatusOK, followStateView{})
return
}
if err != nil {
writeErrWithLog(w, h.logger, "admin follows: get", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, followStateView{
Following: true, ProfileID: uuidToString(f.ProfileID), ProfileName: f.ProfileName,
})
}
type putFollowBody struct {
ArtistName string `json:"artist_name"`
ProfileID string `json:"profile_id"`
WantExisting bool `json:"want_existing"`
}
// handlePutFollow implements PUT /api/admin/follows/{mbid}: follow an artist,
// or change the profile it is followed on. want_existing also wants what the
// artist has already released that the profile passes and isn't held.
func (h *handlers) handlePutFollow(w http.ResponseWriter, r *http.Request) {
admin, ok := requireUser(w, r)
if !ok {
return
}
var body putFollowBody
if !decodeBody(w, r, &body) {
return
}
var profile pgtype.UUID
if body.ProfileID != "" {
if profile, ok = parseUUID(body.ProfileID); !ok {
writeErr(w, apierror.BadRequest("bad_profile", "profile_id is not an id"))
return
}
}
mbid := chi.URLParam(r, "mbid")
if _, err := h.wanted.Follow(r.Context(), wanted.FollowParams{
ArtistMBID: mbid, ArtistName: body.ArtistName, ProfileID: profile,
By: admin.ID, WantExisting: body.WantExisting,
}); err != nil {
switch {
case errors.Is(err, wanted.ErrNotFollowable):
writeErr(w, apierror.BadRequest("not_followable", "Various Artists and MusicBrainz placeholders can't be followed"))
case errors.Is(err, wanted.ErrProfileNotFound):
writeErr(w, apierror.BadRequest("profile_not_found", "no such profile"))
default:
writeErrWithLog(w, h.logger, "admin follows: put", apierror.Internal(err))
}
return
}
h.handleGetFollow(w, r)
}
// handleDeleteFollow implements DELETE /api/admin/follows/{mbid}. What the
// follow already wanted stays on the list.
func (h *handlers) handleDeleteFollow(w http.ResponseWriter, r *http.Request) {
removed, err := h.wanted.Unfollow(r.Context(), chi.URLParam(r, "mbid"))
if err != nil {
writeErrWithLog(w, h.logger, "admin follows: delete", apierror.Internal(err))
return
}
if !removed {
writeErr(w, apierror.NotFound("follow"))
return
}
w.WriteHeader(http.StatusNoContent)
}
// releaseProfileView is a release-type profile.
type releaseProfileView struct {
ID string `json:"id"`
Name string `json:"name"`
PrimaryTypes []string `json:"primary_types"`
SecondaryTypes []string `json:"secondary_types"`
IsDefault bool `json:"is_default"`
FollowCount int64 `json:"follow_count"`
}
// releaseProfilesResponse also carries MusicBrainz's types, so the editor
// offers exactly what a profile may hold.
type releaseProfilesResponse struct {
Profiles []releaseProfileView `json:"profiles"`
PrimaryTypes []string `json:"primary_types"`
SecondaryTypes []string `json:"secondary_types"`
}
// handleListReleaseProfiles implements GET /api/admin/release-profiles.
func (h *handlers) handleListReleaseProfiles(w http.ResponseWriter, r *http.Request) {
rows, err := dbq.New(h.pool).ListReleaseTypeProfiles(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin release profiles: list", apierror.Internal(err))
return
}
out := releaseProfilesResponse{
Profiles: make([]releaseProfileView, 0, len(rows)),
PrimaryTypes: wanted.PrimaryTypes, SecondaryTypes: wanted.SecondaryTypes,
}
for _, p := range rows {
out.Profiles = append(out.Profiles, releaseProfileView{
ID: uuidToString(p.ID), Name: p.Name, PrimaryTypes: p.PrimaryTypes,
SecondaryTypes: p.SecondaryTypes, IsDefault: p.IsDefault, FollowCount: p.FollowCount,
})
}
writeJSON(w, http.StatusOK, out)
}
type releaseProfileBody struct {
Name string `json:"name"`
PrimaryTypes []string `json:"primary_types"`
SecondaryTypes []string `json:"secondary_types"`
IsDefault bool `json:"is_default"`
}
func (b releaseProfileBody) input() wanted.ProfileInput {
return wanted.ProfileInput{
Name: b.Name, PrimaryTypes: b.PrimaryTypes, SecondaryTypes: b.SecondaryTypes, IsDefault: b.IsDefault,
}
}
// handleCreateReleaseProfile implements POST /api/admin/release-profiles.
func (h *handlers) handleCreateReleaseProfile(w http.ResponseWriter, r *http.Request) {
var body releaseProfileBody
if !decodeBody(w, r, &body) {
return
}
p, err := h.wanted.CreateProfile(r.Context(), body.input())
if err != nil {
h.writeProfileErr(w, err)
return
}
writeJSON(w, http.StatusCreated, releaseProfileView{
ID: uuidToString(p.ID), Name: p.Name, PrimaryTypes: p.PrimaryTypes,
SecondaryTypes: p.SecondaryTypes, IsDefault: p.IsDefault,
})
}
// handleUpdateReleaseProfile implements PUT /api/admin/release-profiles/{id}.
// A follow on the profile judges new releases by the new types from its next
// check; releases it has already judged are not judged again.
func (h *handlers) handleUpdateReleaseProfile(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
var body releaseProfileBody
if !decodeBody(w, r, &body) {
return
}
p, err := h.wanted.UpdateProfile(r.Context(), id, body.input())
if err != nil {
h.writeProfileErr(w, err)
return
}
writeJSON(w, http.StatusOK, releaseProfileView{
ID: uuidToString(p.ID), Name: p.Name, PrimaryTypes: p.PrimaryTypes,
SecondaryTypes: p.SecondaryTypes, IsDefault: p.IsDefault,
})
}
// handleDeleteReleaseProfile implements DELETE /api/admin/release-profiles/{id}.
func (h *handlers) handleDeleteReleaseProfile(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
if err := h.wanted.DeleteProfile(r.Context(), id); err != nil {
h.writeProfileErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *handlers) writeProfileErr(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, wanted.ErrProfileInvalid):
writeErr(w, apierror.BadRequest("profile_invalid", err.Error()))
case errors.Is(err, wanted.ErrProfileNotFound):
writeErr(w, apierror.NotFound("profile"))
case errors.Is(err, wanted.ErrProfileNameUsed):
writeErr(w, apierror.Conflict("profile_name_taken", "a profile already has that name"))
case errors.Is(err, wanted.ErrProfileInUse):
writeErr(w, apierror.Conflict("profile_in_use", "artists are followed on this profile; move them first"))
case errors.Is(err, wanted.ErrProfileDefault):
writeErr(w, apierror.Conflict("profile_is_default", "the default profile can't be deleted"))
default:
writeErrWithLog(w, h.logger, "admin release profiles", apierror.Internal(err))
}
}
// deref is a nullable column's text, or "".
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}