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>
242 lines
9.5 KiB
Go
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)
|
|
}
|
|
}
|