feat: search and grab — music release names, a quality ladder, a paced search schedule and one live grab per want (M502 #5521)
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

- 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>
This commit is contained in:
2026-10-10 08:56:47 -04:00
co-authored by Claude Opus 5.5
parent 1a1646a920
commit 89b3b44779
40 changed files with 5332 additions and 49 deletions
+299
View File
@@ -0,0 +1,299 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// Searching for wants and grabbing releases (M502 #5521): the schedule's
// settings, each want's candidates, and a person's Search, Grab and "never
// this one".
// searchTimeout bounds a search a person waits on: a Prowlarr fanning out to
// many trackers can take most of a minute.
const searchTimeout = 2 * time.Minute
type searchSettingsView struct {
AutoGrab bool `json:"auto_grab"`
SearchIntervalHours int32 `json:"search_interval_hours"`
AllowedQualities []string `json:"allowed_qualities"`
Tiers []release.TierInfo `json:"tiers"`
LastPassAt *string `json:"last_pass_at"`
LastPass json.RawMessage `json:"last_pass"`
}
func searchSettings(s dbq.AcquisitionSetting) searchSettingsView {
v := searchSettingsView{
AutoGrab: s.AutoGrab, SearchIntervalHours: s.SearchIntervalHours,
AllowedQualities: s.AllowedQualities, Tiers: release.Tiers, LastPass: s.LastPass,
}
if v.AllowedQualities == nil {
v.AllowedQualities = []string{}
}
if len(v.LastPass) == 0 {
v.LastPass = json.RawMessage("{}")
}
if s.LastPassAt.Valid {
at := formatTimestamp(s.LastPassAt)
v.LastPassAt = &at
}
return v
}
// handleGetSearchSettings implements GET /api/admin/acquisition/settings.
func (h *handlers) handleGetSearchSettings(w http.ResponseWriter, r *http.Request) {
s, err := dbq.New(h.pool).GetAcquisitionSettings(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin search settings: get", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, searchSettings(s))
}
type putSearchSettingsBody struct {
AutoGrab bool `json:"auto_grab"`
SearchIntervalHours int32 `json:"search_interval_hours"`
AllowedQualities []string `json:"allowed_qualities"`
}
// handlePutSearchSettings implements PUT /api/admin/acquisition/settings.
func (h *handlers) handlePutSearchSettings(w http.ResponseWriter, r *http.Request) {
var body putSearchSettingsBody
if !decodeBody(w, r, &body) {
return
}
if body.SearchIntervalHours < 0 || body.SearchIntervalHours > 720 {
writeErr(w, apierror.BadRequest("bad_interval", "search_interval_hours must be between 0 and 720"))
return
}
if len(body.AllowedQualities) == 0 {
writeErr(w, apierror.BadRequest("bad_quality", "allow at least one quality"))
return
}
for _, t := range body.AllowedQualities {
if !release.KnownTier(t) {
writeErr(w, apierror.BadRequest("bad_quality", "unknown quality "+t))
return
}
}
s, err := dbq.New(h.pool).UpdateAcquisitionSettings(r.Context(), dbq.UpdateAcquisitionSettingsParams{
AutoGrab: body.AutoGrab, SearchIntervalHours: body.SearchIntervalHours, AllowedQualities: body.AllowedQualities,
})
if err != nil {
writeErrWithLog(w, h.logger, "admin search settings: put", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, searchSettings(s))
}
// candidateView is one release a want's last search found.
type candidateView struct {
Key string `json:"key"`
Name string `json:"name"`
InfoHash string `json:"info_hash"`
Indexer string `json:"indexer"`
SizeBytes int64 `json:"size_bytes"`
Seeders int32 `json:"seeders"`
FilesCount int32 `json:"files_count"`
Format string `json:"format"`
Quality string `json:"quality"`
Year int32 `json:"year"`
Verdict string `json:"verdict"`
Reason string `json:"reason"`
Reasons json.RawMessage `json:"reasons"`
FoldedFrom []string `json:"folded_from"`
SearchedAt string `json:"searched_at"`
Downloading bool `json:"downloading"`
}
// handleListCandidates implements GET /api/admin/wanted/{id}/candidates.
func (h *handlers) handleListCandidates(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
q := dbq.New(h.pool)
rows, err := q.ListWantCandidates(r.Context(), id)
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: candidates", apierror.Internal(err))
return
}
live, err := q.GetLiveGrab(r.Context(), id)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
writeErrWithLog(w, h.logger, "admin wanted: live grab", apierror.Internal(err))
return
}
out := make([]candidateView, 0, len(rows))
for _, c := range rows {
v := candidateView{
Key: c.Key, Name: c.Name, InfoHash: c.InfoHash, Indexer: c.Indexer, SizeBytes: c.SizeBytes,
Seeders: c.Seeders, FilesCount: c.FilesCount, Format: c.Format, Quality: c.Quality, Year: c.Year,
Verdict: c.Verdict, Reason: c.Reason, Reasons: c.Reasons, FoldedFrom: c.FoldedFrom,
SearchedAt: formatTimestamp(c.SearchedAt),
Downloading: live.ID.Valid && live.Key == c.Key,
}
if v.FoldedFrom == nil {
v.FoldedFrom = []string{}
}
if len(v.Reasons) == 0 {
v.Reasons = json.RawMessage("[]")
}
out = append(out, v)
}
writeJSON(w, http.StatusOK, map[string]any{"items": out})
}
// handleSearchWant implements POST /api/admin/wanted/{id}/search: search
// now, as the schedule would, taking the best release when auto-grab is on.
func (h *handlers) handleSearchWant(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), searchTimeout)
defer cancel()
res, err := h.searcher.SearchNow(ctx, id)
if h.writeSearchErr(w, err) {
return
}
writeJSON(w, http.StatusOK, res)
}
type grabBody struct {
Key string `json:"key"`
Force bool `json:"force"`
}
// grabView is what came of a person's grab.
type grabView struct {
Added bool `json:"added"`
Refused bool `json:"refused"`
Gone bool `json:"gone"`
Unconfigured bool `json:"unconfigured"`
Missing string `json:"missing,omitempty"`
Kept bool `json:"kept"`
Replaced bool `json:"replaced"`
Verdict string `json:"verdict,omitempty"`
Reason string `json:"reason,omitempty"`
Error string `json:"error,omitempty"`
}
// handleGrabCandidate implements POST /api/admin/wanted/{id}/grab: take the
// release named by key. The search runs again first, so the indexer's link
// never leaves the server; force takes what the verdict would not.
func (h *handlers) handleGrabCandidate(w http.ResponseWriter, r *http.Request) {
admin, ok := requireUser(w, r)
if !ok {
return
}
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
var body grabBody
if !decodeBody(w, r, &body) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), searchTimeout)
defer cancel()
took, err := h.searcher.Take(ctx, id, body.Key, body.Force, admin.ID)
if h.writeSearchErr(w, err) {
return
}
v := grabView{
Added: took.Added, Refused: took.Refused, Gone: took.Gone, Unconfigured: took.Unconfigured,
Missing: took.Missing, Error: took.Error,
}
if took.Chosen != nil {
v.Verdict, v.Reason = string(took.Chosen.Verdict), took.Chosen.Reason
}
if took.Contest != nil {
v.Kept, v.Replaced = took.Contest.Kept, took.Contest.Replaced
if took.Contest.Kept {
v.Reason = took.Contest.Reason
}
}
writeJSON(w, http.StatusOK, v)
}
type blockBody struct {
Key string `json:"key"`
}
// handleBlockCandidate implements POST /api/admin/wanted/{id}/block: never
// take this release again, for any want.
func (h *handlers) handleBlockCandidate(w http.ResponseWriter, r *http.Request) {
admin, ok := requireUser(w, r)
if !ok {
return
}
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
var body blockBody
if !decodeBody(w, r, &body) {
return
}
c, err := dbq.New(h.pool).GetWantCandidate(r.Context(), dbq.GetWantCandidateParams{WantID: id, Key: body.Key})
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, apierror.NotFound("candidate"))
return
}
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: block", apierror.Internal(err))
return
}
if c.InfoHash == "" {
writeErr(w, apierror.BadRequest("no_infohash", "this release has no infohash to set aside by"))
return
}
if err := acquire.NewMemory(h.pool).Block(r.Context(), dbq.BlockReleaseParams{
InfoHash: c.InfoHash, Name: c.Name, Reason: acquire.BlockOperator, WantID: id, BlockedBy: admin.ID,
}); err != nil {
writeErrWithLog(w, h.logger, "admin wanted: block", apierror.Internal(err))
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleUnblockRelease implements DELETE /api/admin/release-blocks/{hash}.
func (h *handlers) handleUnblockRelease(w http.ResponseWriter, r *http.Request) {
removed, err := acquire.NewMemory(h.pool).Unblock(r.Context(), chi.URLParam(r, "hash"))
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: unblock", apierror.Internal(err))
return
}
if !removed {
writeErr(w, apierror.NotFound("block"))
return
}
w.WriteHeader(http.StatusNoContent)
}
// writeSearchErr answers a Searcher error, and says whether there was one.
func (h *handlers) writeSearchErr(w http.ResponseWriter, err error) bool {
switch {
case err == nil:
return false
case errors.Is(err, pgx.ErrNoRows):
writeErr(w, apierror.NotFound("want"))
case errors.Is(err, wanted.ErrWantHeld):
writeErr(w, apierror.Conflict("want_held", "the library already holds this album"))
default:
writeErrWithLog(w, h.logger, "admin wanted: search", apierror.Internal(err))
}
return true
}
+35
View File
@@ -35,6 +35,25 @@ type wantView struct {
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.
@@ -118,6 +137,22 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
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)
+11
View File
@@ -77,6 +77,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
acquisition: acquire.NewStore(pool),
catalogue: cat,
wanted: wanted.New(pool, cat, logger),
searcher: wanted.NewSearcher(pool, cat, logger),
catalogueSearches: newSearchCache(),
catalogueArt: catalogue.NewArt(dataDir, artSources(coverSettings)),
leveled: newLeveledRenderer(dataDir, loudSettings, logger),
@@ -248,6 +249,14 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
// profiles that decide which of their releases count.
admin.Get("/wanted", h.handleListWants)
admin.Delete("/wanted/{id}", h.handleRemoveWant)
// Searching and grabbing (M502 #5521).
admin.Get("/acquisition/settings", h.handleGetSearchSettings)
admin.Put("/acquisition/settings", h.handlePutSearchSettings)
admin.Get("/wanted/{id}/candidates", h.handleListCandidates)
admin.Post("/wanted/{id}/search", h.handleSearchWant)
admin.Post("/wanted/{id}/grab", h.handleGrabCandidate)
admin.Post("/wanted/{id}/block", h.handleBlockCandidate)
admin.Delete("/release-blocks/{hash}", h.handleUnblockRelease)
admin.Get("/follows", h.handleListFollows)
admin.Get("/follows/{mbid}", h.handleGetFollow)
admin.Put("/follows/{mbid}", h.handlePutFollow)
@@ -416,6 +425,8 @@ type handlers struct {
// wanted is the wanted list: follows, release-type profiles and wants
// (M502 #5520). Its worker runs in main; the API reads and edits.
wanted *wanted.Service
// searcher searches for wants and grabs releases for them (#5521).
searcher *wanted.Searcher
// catalogueSearches reuses recent MusicBrainz answers; nil asks every
// time, which is what handler tests get.
catalogueSearches *searchCache