Files
minstrel/internal/acquire/search_test.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

242 lines
9.5 KiB
Go

package acquire
import (
"context"
"errors"
"strings"
"testing"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/downloader"
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
)
type fakeSearch struct {
results []indexer.Result
err error
asked []indexer.Query
}
func (f *fakeSearch) Search(_ context.Context, q indexer.Query) ([]indexer.Result, error) {
f.asked = append(f.asked, q)
return f.results, f.err
}
var ram = Want{Want: release.Want{
Artist: "Daft Punk", Title: "Random Access Memories", Year: 2013, PrimaryType: "Album",
MinTracks: 13, MaxTracks: 15,
}}
var anyQuality = release.NewProfile([]string{
"lossless_hires", "lossless", "lossy_high", "lossy_standard", "lossy_low", "lossy_poor", "lossy_trash",
})
func result(title, hash string, seeders int) indexer.Result {
return indexer.Result{Title: title, InfoHash: hash, Seeders: seeders, Indexer: "Knaben", Source: "prowlarr",
Magnet: "magnet:?xt=urn:btih:" + hash}
}
func TestFor_JudgesSortsAndFolds(t *testing.T) {
search := &fakeSearch{results: []indexer.Result{
result("Daft Punk - Random Access Memories (2013) [MP3 320]", "aaaa", 40),
result("Daft Punk Random Access Memories (Drumless Edition 2023) [Flac 24-88]", "bbbb", 90),
result("Daft Punk - Random Access Memories (2013) [FLAC]", "cccc", 12),
result("Daft Punk - Random Access Memories (2013) [FLAC]", "CCCC", 30), // the same torrent from another tracker
result("Daft Punk - Discovery (2001) [FLAC]", "dddd", 50),
}}
s := &Service{Search: search, Profile: anyQuality}
r := s.For(context.Background(), ram)
if len(search.asked) != 1 || search.asked[0].Text != "Daft Punk Random Access Memories" {
t.Fatalf("asked %+v", search.asked)
}
if r.Considered != 5 || r.Folded != 1 || len(r.Candidates) != 4 {
t.Fatalf("considered %d folded %d candidates %d", r.Considered, r.Folded, len(r.Candidates))
}
// Lossless before high-quality lossy among what can be taken; the
// folded copy keeps the higher seeder count.
first, second := r.Candidates[0], r.Candidates[1]
if first.InfoHash != "cccc" || first.Verdict != Grab || first.Seeders != 30 || first.Tier != release.TierLossless {
t.Errorf("first = %s %s %d %s", first.Name, first.Verdict, first.Seeders, first.Tier)
}
if second.InfoHash != "aaaa" || second.Verdict != Grab {
t.Errorf("second = %s %s", second.Name, second.Verdict)
}
for _, c := range r.Candidates[2:] {
if c.Verdict != Rejected || len(c.Reasons) != 1 || c.Reasons[0].Kind != Hard {
t.Errorf("%s: %s %+v", c.Name, c.Verdict, c.Reasons)
}
}
if len(r.Grabbable()) != 2 || !r.ForAPerson() {
t.Errorf("grabbable %d", len(r.Grabbable()))
}
}
func TestFor_ProfileSeedersAndDoubt(t *testing.T) {
search := &fakeSearch{results: []indexer.Result{
result("Daft Punk - Random Access Memories [MP3 128]", "aaaa", 5),
result("Daft Punk - Random Access Memories [FLAC]", "bbbb", 0),
result("Daft Punk - Random Access Memories", "cccc", 9),
}}
s := &Service{Search: search, Profile: release.NewProfile([]string{"lossless", "lossy_high"})}
r := s.For(context.Background(), ram)
verdicts := map[string]Verdict{}
for _, c := range r.Candidates {
verdicts[c.InfoHash] = c.Verdict
}
if verdicts["aaaa"] != Refused || verdicts["bbbb"] != Review || verdicts["cccc"] != Refused {
t.Errorf("verdicts = %v", verdicts)
}
if len(r.Grabbable()) != 0 || !r.ForAPerson() {
t.Error("nothing grabbable, but something for a person")
}
}
func TestFor_BlockedReleasesAreSetAside(t *testing.T) {
c := Candidate{Release: release.Parse("Daft Punk - Random Access Memories [FLAC]"), InfoHash: "aaaa", Seeders: 3}
decide(&c, ram.Want, anyQuality, map[string]string{"aaaa": "You set this release aside."})
if c.Verdict != Blocked || c.Reason != "You set this release aside." {
t.Errorf("verdict %s %q", c.Verdict, c.Reason)
}
}
func TestFor_UnconfiguredAndFailedSearches(t *testing.T) {
if r := (&Service{}).For(context.Background(), ram); !r.Unconfigured {
t.Error("no source is unconfigured")
}
r := (&Service{Search: &fakeSearch{err: indexer.ErrUnconfigured}}).For(context.Background(), ram)
if !r.Unconfigured {
t.Error("ErrUnconfigured is unconfigured")
}
r = (&Service{Search: &fakeSearch{err: errors.New("dial tcp: refused")}}).For(context.Background(), ram)
if r.Error == "" {
t.Error("a failed search says so")
}
}
func TestFanout_ANarrowedSearchKeepsWhatAnswered(t *testing.T) {
up := &fakeSearch{results: []indexer.Result{result("Daft Punk - Random Access Memories [FLAC]", "aaaa", 3)}}
down := &fakeSearch{err: indexer.ErrUnreachable}
f := &Fanout{Sources: []Source{{Code: "prowlarr", Label: "Prowlarr", Search: up}, {Code: "torznab", Label: "Feed", Search: down}}}
r := (&Service{Search: f, Profile: anyQuality}).For(context.Background(), ram)
if r.Error != "" || len(r.Candidates) != 1 || len(r.Narrowed) != 1 || r.Narrowed[0].Source != "torznab" {
t.Errorf("narrowed search: error %q, %d candidates, narrowed %+v", r.Error, len(r.Candidates), r.Narrowed)
}
f.Sources = f.Sources[1:]
if r := (&Service{Search: f}).For(context.Background(), ram); r.Error == "" {
t.Error("every source down is a failed search")
}
}
func TestPacer_SpacesSearchesAndHoldsAfterA429(t *testing.T) {
now := time.Unix(0, 0)
var slept time.Duration
p := &Pacer{Now: func() time.Time { return now }, Sleep: func(_ context.Context, d time.Duration) error { slept += d; return nil }}
src := []Source{{Code: "prowlarr", PerHour: 120, Search: &fakeSearch{}}}
p.spend(src[0])
if !p.Wait(context.Background(), src, now.Add(time.Hour)) || slept != 30*time.Second {
t.Errorf("slept %s, want 30s", slept)
}
if p.Wait(context.Background(), src, now.Add(time.Second)) {
t.Error("waited past the deadline")
}
p.hold("prowlarr", &indexer.RateLimited{RetryAfter: 5 * time.Minute})
if at := p.readyAt(src); at.Sub(now) != 5*time.Minute {
t.Errorf("held until %s", at.Sub(now))
}
}
type fakeClient struct {
downloader.Driver
added []downloader.Add
addErr error
deleted []string
progress float64
}
func (c *fakeClient) AddTorrent(_ context.Context, a downloader.Add) error {
c.added = append(c.added, a)
return c.addErr
}
func (c *fakeClient) Torrents(_ context.Context, hashes ...string) ([]downloader.Torrent, error) {
return []downloader.Torrent{{Hash: hashes[0], Progress: c.progress}}, nil
}
func (c *fakeClient) Delete(_ context.Context, _ bool, hashes ...string) error {
c.deleted = append(c.deleted, hashes...)
return nil
}
func TestGrab_AddsAMagnetToItsOwnFolder(t *testing.T) {
client := &fakeClient{}
s := &Service{Search: &fakeSearch{results: []indexer.Result{
result("Daft Punk - Random Access Memories (2013) [FLAC]", "ABCD", 8),
}}, Profile: anyQuality, Grabber: &Downloader{Client: client, Code: "qbittorrent", Landing: "/downloads"}}
r := s.For(context.Background(), ram)
took := s.Grab(context.Background(), ram, &r.Candidates[0], r.Candidates, false, Origin{By: OriginSchedule})
if !took.Added || took.SavePath != "/downloads/abcd" {
t.Fatalf("took %+v", took)
}
if len(client.added) != 1 || !strings.Contains(strings.ToLower(client.added[0].Magnet), "abcd") || client.added[0].Tags[0] != Tag {
t.Errorf("added %+v", client.added)
}
}
func TestGrab_RefusesWhatTheVerdictWouldNotTake(t *testing.T) {
client := &fakeClient{}
s := &Service{Grabber: &Downloader{Client: client, Landing: "/downloads"}}
review := Candidate{Verdict: Review, InfoHash: "aaaa", Source: indexer.Result{Magnet: "magnet:?xt=urn:btih:aaaa"}}
if took := s.Grab(context.Background(), ram, &review, nil, false, Origin{By: OriginSchedule}); !took.Refused {
t.Error("a review was taken unforced")
}
if took := s.Grab(context.Background(), ram, &review, nil, true, Origin{By: OriginOperator}); !took.Added {
t.Errorf("a forced review was not taken: %+v", took)
}
blocked := Candidate{Verdict: Blocked, InfoHash: "bbbb"}
if took := s.Grab(context.Background(), ram, &blocked, nil, true, Origin{By: OriginOperator}); !took.Refused {
t.Error("forcing took a release set aside")
}
if took := (&Service{}).Grab(context.Background(), ram, &review, nil, true, Origin{}); !took.Unconfigured || took.Missing != MissingClient {
t.Errorf("no client: %+v", took)
}
}
func TestGrab_AClientThatAlreadyHasItCounts(t *testing.T) {
client := &fakeClient{addErr: downloader.ErrDuplicate}
s := &Service{Grabber: &Downloader{Client: client, Landing: "/downloads"}}
c := Candidate{Verdict: Grab, InfoHash: "aaaa", Source: indexer.Result{Magnet: "magnet:?xt=urn:btih:aaaa"}}
if took := s.Grab(context.Background(), ram, &c, nil, false, Origin{By: OriginSchedule}); !took.Added {
t.Errorf("a duplicate: %+v", took)
}
}
func TestBetter_QualityThenSeedersThenYear(t *testing.T) {
flac := Candidate{Verdict: Grab, Tier: release.TierLossless, Seeders: 2}
mp3 := Candidate{Verdict: Grab, Tier: release.TierLossyHigh, Seeders: 90}
if !better(flac, mp3) || better(mp3, flac) {
t.Error("quality first among grabs")
}
busy := Candidate{Verdict: Grab, Tier: release.TierLossless, Seeders: 20}
if !better(busy, flac) {
t.Error("then seeders")
}
dated := Candidate{Verdict: Grab, Tier: release.TierLossless, Seeders: 2, YearAgrees: true}
if !better(dated, flac) {
t.Error("then the year")
}
if better(Candidate{Verdict: Review}, flac) || !better(flac, Candidate{Verdict: Review}) {
t.Error("a grab ranks above a review")
}
}
func TestLandingFor_OneFolderPerTorrent(t *testing.T) {
if got := landingFor("/downloads", "abcd", "x"); got != "/downloads/abcd" {
t.Error(got)
}
if got := landingFor("/downloads", "", "Daft Punk: R.A.M. [FLAC]"); got != "/downloads/daft-punk-r-a-m--flac" {
t.Error(got)
}
}