Files
minstrel/internal/api/admin_wanted.go
T
bvandeusenandClaude Opus 5.5 89b3b44779
release / govulncheck (push) Successful in 21s
release / go (push) Failing after 33s
release / web (push) Successful in 56s
release / integration (push) Failing after 4m48s
release / android (push) Successful in 5m6s
release / Build signed APK (releases and dev) (push) Successful in 5m16s
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: search and grab — music release names, a quality ladder, a paced search schedule and one live grab per want (M502 #5521)
- internal/release: reads a release name (format, bitrate, bit depth and
  sample rate, source, year, video, discography) and judges it against the
  album wanted: the artist and title must be in it; video rips,
  discographies, other versions (drumless, instrumental, karaoke, remix,
  live unless the album is live…) and singles or EPs for an album are
  refused. Quality tiers on Lidarr's ladder; a .torrent's file list is read
  for format, video files and a track count short of the album.
- internal/acquire: fan-out across every search source with a narrowed
  search kept, a process-wide pacer per source, every candidate judged and
  kept with its reasons, and the grab: one live grab per want, tie-broken
  (a person's grab beats the schedule's; a better release replaces a worse
  one unless it is nearly done). Each download lands in its own folder
  under the client's downloads folder.
- internal/wanted: the search schedule, every ten minutes, never-searched
  first, then requests, missing files and followed artists, taking the
  best release when auto-grab is on (the default).
- Migration 0082: acquisition_settings (auto-grab, interval, allowed
  qualities seeded from Lidarr's "Any"), want search columns,
  want_candidates, grabs, release_blocks.
- Web: the Wanted page shows each want's last search, a Search button and
  every release found with its verdict (Grab, Grab anyway, Never this one,
  Allow again). Integrations gains "Searching and grabbing".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:56:47 -04:00

428 lines
14 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"`
}
type wantGrabView struct {
Name string `json:"name"`
GrabbedAt string `json:"grabbed_at"`
Origin string `json:"origin"`
Quality string `json:"quality"`
}
// 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"`
}
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},
}
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{Name: *row.GrabName, GrabbedAt: formatTimestamp(row.GrabbedAt)}
if row.GrabOrigin != nil {
v.Grab.Origin = *row.GrabOrigin
}
if row.GrabQuality != nil {
v.Grab.Quality = *row.GrabQuality
}
}
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))
}
}