fix(download): try the next acceptable copy when a transfer fails
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
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user