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
+390
View File
@@ -0,0 +1,390 @@
package acquire
import (
"context"
"errors"
"sort"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
)
// One search for one want, and what could be done about each result (M502
// #5521), ported from Fabled Librarian's internal/acquire. internal/release
// says whether a name is the album and what quality it is; this package
// asks in the right order and says why in words a person can act on.
// Verdict is what can be done about one result.
type Verdict string
const (
// Grab: the album, in a quality the profile takes.
Grab Verdict = "grab"
// Review: maybe the album; a person decides.
Review Verdict = "review"
// Refused: the album, in a quality the profile does not take. The only
// verdict that names a setting.
Refused Verdict = "refused"
// Rejected: not the album.
Rejected Verdict = "rejected"
// Blocked: a release set aside, by a person or by its files.
Blocked Verdict = "blocked"
)
// order sorts verdicts: what can be taken first, what was given up on last.
var order = map[Verdict]int{Grab: 0, Review: 1, Refused: 2, Rejected: 3, Blocked: 4}
// Reason kinds: a hard reason is a fact about the bytes, a soft one about the
// moment or a setting.
const (
Hard = "hard"
Soft = "soft"
)
// Reason codes beyond internal/release's vetoes.
const (
CodeBlocked = "blocked"
CodeProfile = "profile"
CodeNoSeeders = "no_seeders"
CodeDoubt = "doubt"
)
// Reason is one thing a verdict rests on.
type Reason struct {
Kind string `json:"kind"`
Code string `json:"code"`
Text string `json:"text"`
}
// Want is the album searched for: its wants row and how the catalogue
// describes it.
type Want struct {
ID pgtype.UUID
release.Want
}
// Candidate is one result with everything decided about it.
type Candidate struct {
release.Release
// Source is the indexer's own result. It never leaves the server: its
// DownloadURL can carry the indexer's key.
Source indexer.Result
InfoHash string
Indexer string
SizeBytes int64
Seeders int
SwarmKnown bool
Category int
FilesCount int
// Files is the torrent's file list once its .torrent has been read.
Files []release.File
FoldedFrom []string
Tier release.Tier
YearAgrees bool
Verdict Verdict
Reason string
Reasons []Reason
}
// Key is how a candidate is named across a search and a grab: its infohash,
// or its name when the source gave none.
func Key(c Candidate) string {
if c.InfoHash != "" {
return c.InfoHash
}
return c.Name
}
// Result is one search.
type Result struct {
Query string
// Unconfigured: no search source is set up.
Unconfigured bool
// Error is a search that failed outright.
Error string
// Narrowed names sources that did not answer on a search that otherwise
// did.
Narrowed []Narrowing
Considered int
Folded int
// Looked is how many .torrent files were read for their file lists.
Looked int
Candidates []Candidate
// KeepError: the candidates could not be stored.
KeepError string
}
// Grabbable is what may be taken, best first.
func (r Result) Grabbable() []Candidate {
var out []Candidate
for _, c := range r.Candidates {
if c.Verdict == Grab {
out = append(out, c)
}
}
return out
}
// ForAPerson says whether anything found is worth a person's look: a
// release that may be the album, whatever its quality.
func (r Result) ForAPerson() bool {
for _, c := range r.Candidates {
if c.Verdict == Grab || c.Verdict == Review || c.Verdict == Refused {
return true
}
}
return false
}
// Service searches and grabs.
type Service struct {
// Search is nil when no source is configured.
Search Searcher
// Grabber is nil when no download client is configured.
Grabber *Downloader
Profile release.Profile
// Memory stores candidates, grabs and blocks. Nil keeps nothing.
Memory *Memory
}
// Query is the text sent to the indexers: the artist and the title, without
// a trailing bracketed part the release names usually leave off.
func Query(w release.Want) string {
title := w.Title
if i := strings.IndexAny(title, "(["); i > 0 {
title = title[:i]
}
return strings.Join(strings.Fields(strings.TrimSpace(w.Artist)+" "+strings.TrimSpace(title)), " ")
}
// For searches for one want and judges everything that comes back. It never
// returns an error: a search that could not be made is a Result saying so.
func (s *Service) For(ctx context.Context, want Want) Result {
r := Result{Query: Query(want.Want)}
if s == nil || s.Search == nil {
r.Unconfigured = true
return r
}
found, err := s.Search.Search(ctx, indexer.Query{Text: r.Query, Categories: indexer.MusicCategories})
// A partial search arrives as results and an error together.
if narrowed, answered := narrowings(err); narrowed != nil && answered {
r.Narrowed, err = narrowed, nil
}
switch {
case errors.Is(err, indexer.ErrUnconfigured):
r.Unconfigured = true
return r
case err != nil:
r.Error = indexer.FirstLine(err.Error())
return r
}
r.Considered = len(found)
cands, folded := fold(found)
r.Folded = folded
blocked := s.Memory.blocked(ctx)
for i := range cands {
decide(&cands[i], want.Want, s.Profile, blocked)
}
sortCandidates(cands)
for _, i := range s.peek(ctx, cands) {
decide(&cands[i], want.Want, s.Profile, blocked)
r.Looked++
}
sortCandidates(cands)
r.Candidates = cands
if s.Memory != nil && want.ID.Valid {
if err := s.Memory.Keep(ctx, want.ID, cands); err != nil {
r.KeepError = indexer.FirstLine(err.Error())
}
}
return r
}
// decide works out one candidate's verdict, in order: given up on, then
// whether it is the album, then whether the profile takes its quality, then
// whether anything leaves it to a person. A quality refusal said about the
// wrong album would be noise, so identity comes first.
func decide(c *Candidate, w release.Want, profile release.Profile, blocked map[string]string) {
c.Verdict, c.Reason, c.Reasons = "", "", nil
if why, ok := blocked[c.InfoHash]; ok && c.InfoHash != "" {
set(c, Blocked, Hard, CodeBlocked, why)
return
}
m := release.Judge(w, c.Release)
c.YearAgrees = m.YearAgrees
if m.Veto != "" {
set(c, Rejected, Hard, m.Veto, m.Reason)
return
}
doubt := m.Doubt
if len(c.Files) > 0 {
if in := release.Inspect(c.Files); in.Format != "" {
c.Format = in.Format
}
veto, reason, fileDoubt := release.JudgeFiles(w, c.Files)
if veto != "" {
set(c, Rejected, Hard, veto, reason)
return
}
if doubt == "" {
doubt = fileDoubt
}
}
c.Tier = release.TierOf(c.Release, c.Category)
if !profile.Allows(c.Tier) {
set(c, Refused, Soft, CodeProfile, "Your quality settings don’t take "+strings.ToLower(release.Label(c.Tier))+".")
return
}
if doubt != "" {
set(c, Review, Soft, CodeDoubt, doubt)
return
}
if c.Seeders == 0 {
set(c, Review, Soft, CodeNoSeeders, "Nobody is sharing it right now.")
return
}
c.Verdict = Grab
c.Reason = "The album, " + strings.ToLower(release.Label(c.Tier)) + "."
if d := release.Describe(c.Release); d != "" {
c.Reason = "The album, " + d + "."
}
}
func set(c *Candidate, v Verdict, kind, code, text string) {
c.Verdict, c.Reason = v, text
c.Reasons = append(c.Reasons, Reason{Kind: kind, Code: code, Text: text})
}
// sortCandidates puts the best first, stably so the same search proposes the
// same grab twice.
func sortCandidates(cands []Candidate) {
sort.SliceStable(cands, func(a, b int) bool { return better(cands[a], cands[b]) })
}
// better says whether x ranks above y: verdict, then quality, then how many
// share it, then whether its year agrees.
func better(x, y Candidate) bool {
if order[x.Verdict] != order[y.Verdict] {
return order[x.Verdict] < order[y.Verdict]
}
if rx, ry := release.Rank(x.Tier), release.Rank(y.Tier); rx != ry {
return rx > ry
}
if x.Seeders != y.Seeders {
return x.Seeders > y.Seeders
}
return x.YearAgrees && !y.YearAgrees
}
// fold merges results that are the same torrent, by infohash. Prowlarr
// aggregates, so one release arrives once per tracker that lists it. A
// result without a hash is never folded: an absent hash is not a shared one.
func fold(found []indexer.Result) ([]Candidate, int) {
out := make([]Candidate, 0, len(found))
at := make(map[string]int, len(found))
folded := 0
for _, f := range found {
if strings.TrimSpace(f.Title) == "" {
continue
}
hash := indexer.NormalizeHash(f.InfoHash)
if hash == "" && f.Magnet != "" {
hash = indexer.MagnetHash(f.Magnet)
}
if hash != "" {
if i, seen := at[hash]; seen {
folded++
if f.Indexer != "" {
out[i].FoldedFrom = append(out[i].FoldedFrom, f.Indexer)
}
// A measured swarm beats a reported one; among like, the
// highest count.
switch {
case f.SwarmKnown && !out[i].SwarmKnown:
out[i].Seeders, out[i].SwarmKnown = f.Seeders, true
case f.SwarmKnown == out[i].SwarmKnown && f.Seeders > out[i].Seeders:
out[i].Seeders = f.Seeders
}
if f.FilesCount > 0 && out[i].FilesCount == 0 {
out[i].FilesCount = f.FilesCount
}
continue
}
at[hash] = len(out)
}
out = append(out, Candidate{
Release: release.Parse(f.Title), Source: f, InfoHash: hash, Indexer: f.Indexer,
SizeBytes: f.SizeBytes, Seeders: f.Seeders, SwarmKnown: f.SwarmKnown,
Category: f.Category, FilesCount: f.FilesCount,
})
}
return out, folded
}
// maxPeeks is how many .torrent files one search reads. Few: each goes
// through Prowlarr to a tracker, and Prowlarr answers 429 to a burst.
const maxPeeks = 3
// peek reads the .torrent of candidates that are the album but whose name
// states no format, so their files can say what they are; it returns which
// now carry a file list. A failed fetch leaves the candidate as it was.
func (s *Service) peek(ctx context.Context, cands []Candidate) []int {
f, ok := s.Search.(Fetcher)
if !ok {
return nil
}
var looked []int
for i := range cands {
if len(looked) >= maxPeeks || ctx.Err() != nil {
break
}
c := &cands[i]
if c.Tier != release.TierUnknown || (c.Verdict != Grab && c.Verdict != Refused) ||
c.Files != nil || c.Source.DownloadURL == "" || c.Source.Magnet != "" {
continue
}
body, err := f.Download(ctx, c.Source)
if err != nil {
continue
}
meta, err := release.ParseTorrent(body)
if err != nil {
continue
}
c.Files = meta.Files
if c.InfoHash == "" {
c.InfoHash = meta.InfoHash
}
if c.FilesCount == 0 {
c.FilesCount = len(meta.Files)
}
looked = append(looked, i)
}
return looked
}
// pick finds a candidate by key: the infohash first, the exact name for a
// result that has none.
func pick(cands []Candidate, key string) *Candidate {
key = strings.TrimSpace(key)
if key == "" {
return nil
}
hash := indexer.NormalizeHash(key)
for i := range cands {
if cands[i].InfoHash != "" && cands[i].InfoHash == hash {
return &cands[i]
}
}
for i := range cands {
if cands[i].InfoHash == "" && cands[i].Name == key {
return &cands[i]
}
}
return nil
}