A failed transfer failed the whole download. On Soulseek the usual failure is one peer being offline or refusing, and a popular album has several other peers offering the same folder; the ranked list that names them was already held in m.results and nothing walked it. grab now loops: when a candidate's transfer fails, or delivers too little of the album to import, the next candidate is tried in its place, up to three in all. Three rules keep that honest: - Only a candidate auto-pick would itself have accepted is offered, so a second choice clears the same match, quality and guardrail gates as the first. - On Soulseek the failure is the peer's, so every folder that peer offered is skipped with it; elsewhere only the failed release is. - A candidate the user picked by hand does not fall back. They chose that copy, and quietly substituting another is a decision they did not make. The same change fixes auto-pick grabbing the wrong candidate. AutoPickVeto judges the best candidate inside the user's guardrails, but Start and Attempt then grabbed ranked[0] -- so when the overall best was over the size ceiling, the veto passed on the strength of the second and the first was downloaded anyway: the one copy the user had said not to take unattended. autoPick returns the candidate the veto actually judged. Closes #263 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT
262 lines
6.8 KiB
Go
262 lines
6.8 KiB
Go
package download
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
)
|
|
|
|
// A transfer that fails on one copy of an album is not a failed
|
|
// download while another acceptable copy exists. On Soulseek the usual
|
|
// failure is one peer being offline, with several others offering the
|
|
// same folder.
|
|
|
|
var errPeerOffline = errors.New("peer went offline")
|
|
|
|
func TestManagerFallsBackToTheNextCandidate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newManagerFixture(t)
|
|
|
|
// The failing source ranks first on priority, so the fallback is
|
|
// what reaches the one that works.
|
|
bad := fakeWithAlbum(1, "offline-peer", ".flac")
|
|
bad.GrabErr = errPeerOffline
|
|
good := fakeWithAlbum(2, "online-peer", ".flac")
|
|
|
|
f.manager.installProvider(Config{ID: 1, Priority: 90}, bad)
|
|
f.manager.installProvider(Config{ID: 2, Priority: 10}, good)
|
|
|
|
dl := fourTrackDownload()
|
|
|
|
if _, err := f.manager.Start(context.Background(), dl); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
|
|
waitForDownloadState(t, f.store, dl.ID, StateComplete)
|
|
|
|
if bad.GrabCalls != 1 || good.GrabCalls != 1 {
|
|
t.Errorf(
|
|
"grabs: failing=%d working=%d, want 1 and 1",
|
|
bad.GrabCalls, good.GrabCalls,
|
|
)
|
|
}
|
|
|
|
// The abandoned attempt's staging goes with it; only a request that
|
|
// fails outright keeps its staging for inspection.
|
|
waitFor(t, func() bool {
|
|
entries, err := os.ReadDir(f.staging.Root())
|
|
|
|
return err == nil && len(entries) == 0
|
|
}, "the failed attempt's staging was never released")
|
|
}
|
|
|
|
// Falling back must not lower the bar. A second choice outside the
|
|
// user's guardrails is not a choice auto-pick may make, first or second.
|
|
func TestManagerFallbackRespectsTheGuardrails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newManagerFixture(t)
|
|
f.manager.SetPreferences(AutoDownloadPrefs{MaxSizeMB: 50})
|
|
|
|
bad := fakeWithAlbum(1, "offline-peer", ".flac")
|
|
bad.GrabErr = errPeerOffline
|
|
bad.Candidates[0].TotalSize = 40 << 20
|
|
|
|
huge := fakeWithAlbum(2, "oversized", ".flac")
|
|
huge.Candidates[0].TotalSize = 900 << 20
|
|
|
|
f.manager.installProvider(Config{ID: 1, Priority: 90}, bad)
|
|
f.manager.installProvider(Config{ID: 2, Priority: 10}, huge)
|
|
|
|
dl := fourTrackDownload()
|
|
|
|
if _, err := f.manager.Start(context.Background(), dl); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
|
|
waitForDownloadState(t, f.store, dl.ID, StateFailed)
|
|
|
|
if huge.GrabCalls != 0 {
|
|
t.Errorf("fell back to a candidate over the size ceiling")
|
|
}
|
|
}
|
|
|
|
// A copy the user picked by hand is the copy they asked for. Quietly
|
|
// substituting another is a decision they did not make.
|
|
func TestManagerPickDoesNotFallBack(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newManagerFixture(t)
|
|
|
|
bad := fakeWithAlbum(1, "offline-peer", ".flac")
|
|
bad.GrabErr = errPeerOffline
|
|
good := fakeWithAlbum(2, "online-peer", ".flac")
|
|
|
|
f.manager.installProvider(Config{ID: 1, Priority: 90}, bad)
|
|
f.manager.installProvider(Config{ID: 2, Priority: 10}, good)
|
|
|
|
// A ceiling below both copies parks the result set for the user.
|
|
f.manager.SetPreferences(AutoDownloadPrefs{MaxSizeMB: 1})
|
|
|
|
bad.Candidates[0].TotalSize = 30 << 20
|
|
good.Candidates[0].TotalSize = 30 << 20
|
|
|
|
dl := fourTrackDownload()
|
|
|
|
if _, err := f.manager.Start(context.Background(), dl); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
|
|
if err := f.manager.Pick(
|
|
context.Background(), dl.ID, "offline-peer-cand",
|
|
); err != nil {
|
|
t.Fatalf("Pick: %v", err)
|
|
}
|
|
|
|
waitForDownloadState(t, f.store, dl.ID, StateFailed)
|
|
|
|
if good.GrabCalls != 0 {
|
|
t.Errorf("a hand-picked grab fell back to another candidate")
|
|
}
|
|
}
|
|
|
|
// Fallback is for surviving an offline peer or two, not for walking a
|
|
// forty-peer list for six hours.
|
|
func TestManagerFallbackIsBounded(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newManagerFixture(t)
|
|
|
|
var providers []*FakeProvider
|
|
|
|
for i := int64(1); i <= maxGrabAttempts+2; i++ {
|
|
p := fakeWithAlbum(i, "peer-"+itoa(int(i)), ".flac")
|
|
p.GrabErr = errPeerOffline
|
|
|
|
f.manager.installProvider(Config{ID: i, Priority: 50}, p)
|
|
providers = append(providers, p)
|
|
}
|
|
|
|
dl := fourTrackDownload()
|
|
|
|
if _, err := f.manager.Start(context.Background(), dl); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
|
|
waitForDownloadState(t, f.store, dl.ID, StateFailed)
|
|
|
|
grabs := 0
|
|
for _, p := range providers {
|
|
grabs += p.GrabCalls
|
|
}
|
|
|
|
if grabs != maxGrabAttempts {
|
|
t.Errorf("grabs = %d, want %d", grabs, maxGrabAttempts)
|
|
}
|
|
}
|
|
|
|
// The veto judges the best candidate *inside* the guardrails, so the
|
|
// grab has to take that one — not the overall best, which may be the
|
|
// very copy the user said not to take unattended.
|
|
func TestManagerAutoPickTakesTheBestEligibleCandidate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newManagerFixture(t)
|
|
f.manager.SetPreferences(AutoDownloadPrefs{MaxSizeMB: 50})
|
|
|
|
huge := fakeWithAlbum(1, "oversized", ".flac")
|
|
huge.Candidates[0].TotalSize = 900 << 20
|
|
|
|
fits := fakeWithAlbum(2, "fits", ".flac")
|
|
fits.Candidates[0].TotalSize = 40 << 20
|
|
|
|
f.manager.installProvider(Config{ID: 1, Priority: 90}, huge)
|
|
f.manager.installProvider(Config{ID: 2, Priority: 10}, fits)
|
|
|
|
dl := fourTrackDownload()
|
|
|
|
ranked, err := f.manager.Start(context.Background(), dl)
|
|
if err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
|
|
if ranked[0].ID != "oversized-cand" {
|
|
t.Fatalf("fixture: best overall is %s, want the oversized copy", ranked[0].ID)
|
|
}
|
|
|
|
waitForDownloadState(t, f.store, dl.ID, StateComplete)
|
|
|
|
if huge.GrabCalls != 0 || fits.GrabCalls != 1 {
|
|
t.Errorf(
|
|
"grabs: oversized=%d fits=%d, want 0 and 1",
|
|
huge.GrabCalls, fits.GrabCalls,
|
|
)
|
|
}
|
|
}
|
|
|
|
// On Soulseek a failure is the peer's, so every folder that peer offered
|
|
// goes with it. Elsewhere a failure is the release's, and one indexer's
|
|
// other releases are still worth trying.
|
|
func TestRuledOutBy(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
failed := []Candidate{
|
|
{ID: "slskd:alice:Album", Kind: KindSlskd, ProviderID: 1, Origin: "alice"},
|
|
{ID: "tracker-1", Kind: KindProwlarr, ProviderID: 2, Origin: "indexer"},
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
c Candidate
|
|
want bool
|
|
}{
|
|
{
|
|
name: "the same candidate",
|
|
c: Candidate{ID: "tracker-1", Kind: KindProwlarr, ProviderID: 2, Origin: "indexer"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "another folder from a failed peer",
|
|
c: Candidate{
|
|
ID: "slskd:alice:Album (2)",
|
|
Kind: KindSlskd,
|
|
ProviderID: 1,
|
|
Origin: "alice",
|
|
},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "another peer",
|
|
c: Candidate{ID: "slskd:bob:Album", Kind: KindSlskd, ProviderID: 1, Origin: "bob"},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "another release from the same indexer",
|
|
c: Candidate{ID: "tracker-2", Kind: KindProwlarr, ProviderID: 2, Origin: "indexer"},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "a peer of the same name on a different daemon",
|
|
c: Candidate{
|
|
ID: "slskd:alice:Album",
|
|
Kind: KindSlskd,
|
|
ProviderID: 3,
|
|
Origin: "alice",
|
|
},
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if got := ruledOutBy(tc.c, failed); got != tc.want {
|
|
t.Errorf("ruledOutBy = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|