release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 27s
release / Verify release artifacts (tag releases only) (push) Skipped
- acquisition_settings.upgrade_until (default High quality lossy, Lidarr's cutoff, note #5527; "" is off), an "Upgrade until" row under Searching. - The wanted worker judges held albums hourly: tier per format from size per second (the scanner records no bitrate), the album's worst. Below the cutoff it is wanted for quality; a video rip among its tracks always. - The schedule searches upgrades weekly, after open wants; a release no better than the copy held is refused ("not_better"). - The import matches each file to the track it would replace (recording, then disc/track) and replaces it when it is a rip or the file is a better tier: over the same path in one rename, or filed beside it and the old track folded in (library.ReplaceTrack: file first, then foldTrackInto). Nothing better: the release is set aside. - Collection › Upgrades (Lidarr's Cutoff Unmet); Suspect sources says when a clean copy was looked for instead of Lidarr's search. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
264 lines
10 KiB
Go
264 lines
10 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, 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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|