package acquire import ( "context" "errors" "fmt" "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, 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 // elsewhere: the client holds it but does not list it for Minstrel. elsewhere bool } 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) { if c.elsewhere { return nil, nil } 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) } } // A duplicate the client does not list for Minstrel sits under another // category, where it would read as gone half an hour later (#6176). It is set // aside rather than taken; forced, it is refused with the client's reason. func TestGrab_ADuplicateHeldOutsideMinstrelIsNotTaken(t *testing.T) { client := &fakeClient{addErr: fmt.Errorf("%w: already downloading Ohio Players (lidarr)", downloader.ErrDuplicate), elsewhere: true} s := &Service{Grabber: &Downloader{Client: client, Landing: "/downloads"}} c := Candidate{Verdict: Grab, InfoHash: "aaaa", Source: indexer.Result{Magnet: "magnet:?xt=urn:btih:aaaa"}} took := s.Grab(context.Background(), ram, &c, nil, false, Origin{By: OriginSchedule}) if took.Added || !took.Refused { t.Fatalf("took %+v", took) } if c.Verdict != Blocked || !strings.Contains(c.Reason, "(already downloading Ohio Players (lidarr))") { t.Errorf("candidate %s: %q", c.Verdict, c.Reason) } forced := Candidate{Verdict: Grab, InfoHash: "aaaa", Source: indexer.Result{Magnet: "magnet:?xt=urn:btih:aaaa"}} took = s.Grab(context.Background(), ram, &forced, nil, true, Origin{By: OriginOperator}) if took.Added || !strings.Contains(took.Error, "outside Minstrel's category") { t.Errorf("forced: %+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) } } func TestDecide_AnUpgradeTakesOnlyABetterCopy(t *testing.T) { up := ram up.Floor, up.Held = release.TierLossyLow, "MP3, about 192 kbps" same := Candidate{Release: release.Parse("Daft Punk - Random Access Memories [MP3 192]"), InfoHash: "aaaa", Seeders: 3} decide(&same, up, anyQuality, nil) if same.Verdict != Refused || same.Reasons[0].Code != CodeNotBetter || same.Reason != "No better than the copy you have (MP3, about 192 kbps)." { t.Errorf("same quality: %s %q", same.Verdict, same.Reason) } flac := Candidate{Release: release.Parse("Daft Punk - Random Access Memories [FLAC]"), InfoHash: "bbbb", Seeders: 3} decide(&flac, up, anyQuality, nil) if flac.Verdict != Grab { t.Errorf("a FLAC over a 192 MP3: %s %q", flac.Verdict, flac.Reason) } // A video rip has no floor: any clean copy is better. rip := ram decide(&same, rip, anyQuality, nil) if same.Verdict != Grab { t.Errorf("no floor: %s %q", same.Verdict, same.Reason) } }