The slskd search asked one question and ignored part of the answer. Two queries. Soulseek matches every term against a file's full path, so every extra word is a filter, and several filter wrongly: an edition qualifier from the catalog title that no one puts in a folder name, a term with a leading "-", which Soulseek reads as an exclusion, and "Various Artists", which is in no one's path. When a normalised form of the request differs, it runs alongside the original and the candidates are merged by peer and folder. Concurrently, not as a fallback: the manager gives a provider one search budget, and a Soulseek search spends most of it waiting. A query the user typed is searched as written. Stated options. The search carried only its id and text, so slskd's own defaults for its timeout and response limits applied. Its timeout is now set inside our wait, the limits are well above a popular album, and slskd drops folders below the file floor and peers with a queue we would not reach today. A state-only poll. Every one-second poll re-sent every response; the responses are now fetched once at the end, falling back to the old includeResponses form for a daemon without that endpoint. Durations. slskd reports each file's length and it was discarded. It is now carried as CandidateFile.LengthMillis and scored against the expected tracks as DurationFit, which takes 0.15 of title fit's weight when at least half the aligned pairs are timed: a title says which song a file claims to be, a length says whether it is that recording. Without lengths the score is exactly the previous formula. freeUploadSlots is removed from the response type; slskd sends hasFreeUploadSlot and nothing by that name. Closes #271 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT
935 lines
22 KiB
Go
935 lines
22 KiB
Go
package download
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// slskd tests run against an httptest server shaped like the real API.
|
|
// No daemon, no Soulseek account, no network.
|
|
|
|
// slskdStub is a fake slskd daemon.
|
|
type slskdStub struct {
|
|
server *httptest.Server
|
|
|
|
mu sync.Mutex
|
|
|
|
// responses is what a search returns.
|
|
responses []slskdResponse
|
|
|
|
// transfers is what the downloads endpoint reports, in order; the
|
|
// last entry repeats.
|
|
transfers [][]slskdTransfer
|
|
pollCount int
|
|
|
|
// before is what the downloads endpoint reports until something is
|
|
// enqueued: records slskd already held from earlier attempts.
|
|
before []slskdTransfer
|
|
|
|
// enqueued records what was requested for download.
|
|
enqueued []map[string]any
|
|
posted bool
|
|
|
|
// paths records the escaped path of every transfers call, and
|
|
// cancelled the escaped request URI of every DELETE.
|
|
paths []string
|
|
cancelled []string
|
|
|
|
// unauthorized makes every call return 401.
|
|
unauthorized bool
|
|
|
|
// searches records every search request body, and searchGets the
|
|
// request URI of every search GET.
|
|
searches []map[string]any
|
|
searchGets []string
|
|
|
|
// noResponsesEndpoint makes /searches/{id}/responses 404, as an
|
|
// older daemon would.
|
|
noResponsesEndpoint bool
|
|
}
|
|
|
|
func newSlskdStub(t *testing.T) *slskdStub {
|
|
t.Helper()
|
|
|
|
s := &slskdStub{}
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("/api/v0/application", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
writeJSON(t, w, map[string]any{"version": "0.21.0"})
|
|
})
|
|
|
|
mux.HandleFunc("/api/v0/searches", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
var body map[string]any
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Errorf("decode search body: %v", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.searches = append(s.searches, body)
|
|
s.mu.Unlock()
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
})
|
|
|
|
mux.HandleFunc("/api/v0/searches/", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
if r.Method == http.MethodDelete {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
responses := s.responses
|
|
noEndpoint := s.noResponsesEndpoint
|
|
s.searchGets = append(s.searchGets, r.URL.RequestURI())
|
|
s.mu.Unlock()
|
|
|
|
if strings.HasSuffix(r.URL.Path, "/responses") {
|
|
if noEndpoint {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
writeJSON(t, w, responses)
|
|
|
|
return
|
|
}
|
|
|
|
if r.URL.Query().Get("includeResponses") != "true" {
|
|
responses = nil
|
|
}
|
|
|
|
writeJSON(t, w, slskdSearch{
|
|
ID: "search-1",
|
|
IsComplete: true,
|
|
Responses: responses,
|
|
})
|
|
})
|
|
|
|
mux.HandleFunc("/api/v0/transfers/downloads/", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.paths = append(s.paths, r.URL.EscapedPath())
|
|
s.mu.Unlock()
|
|
|
|
switch r.Method {
|
|
case http.MethodPost:
|
|
var body []map[string]any
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Errorf("decode enqueue body: %v", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.enqueued = body
|
|
s.posted = true
|
|
s.mu.Unlock()
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
|
|
return
|
|
case http.MethodDelete:
|
|
s.mu.Lock()
|
|
s.cancelled = append(s.cancelled, r.URL.RequestURI())
|
|
s.mu.Unlock()
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
|
|
if !s.posted {
|
|
before := s.before
|
|
s.mu.Unlock()
|
|
|
|
writeJSON(t, w, map[string]any{
|
|
"directories": []map[string]any{{"files": before}},
|
|
})
|
|
|
|
return
|
|
}
|
|
|
|
idx := s.pollCount
|
|
if idx >= len(s.transfers) {
|
|
idx = len(s.transfers) - 1
|
|
} else {
|
|
s.pollCount++
|
|
}
|
|
|
|
var batch []slskdTransfer
|
|
if idx >= 0 && len(s.transfers) > 0 {
|
|
batch = s.transfers[idx]
|
|
}
|
|
|
|
s.mu.Unlock()
|
|
|
|
writeJSON(t, w, map[string]any{
|
|
"directories": []map[string]any{{"files": batch}},
|
|
})
|
|
})
|
|
|
|
s.server = httptest.NewServer(mux)
|
|
t.Cleanup(s.server.Close)
|
|
|
|
return s
|
|
}
|
|
|
|
// reject enforces API-key auth like the real daemon.
|
|
func (s *slskdStub) reject(w http.ResponseWriter, r *http.Request) bool {
|
|
s.mu.Lock()
|
|
unauthorized := s.unauthorized
|
|
s.mu.Unlock()
|
|
|
|
if unauthorized || r.Header.Get("X-Api-Key") != "test-key" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
|
|
t.Helper()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
t.Errorf("encode response: %v", err)
|
|
}
|
|
}
|
|
|
|
// newStubSlskd builds the provider pointed at the stub, with a real
|
|
// temp directory standing in for slskd's downloads folder.
|
|
func newStubSlskd(t *testing.T, stub *slskdStub) (*slskd, string) {
|
|
t.Helper()
|
|
|
|
downloads := t.TempDir()
|
|
|
|
p, err := newSlskd(
|
|
Config{
|
|
ID: 1,
|
|
Kind: KindSlskd,
|
|
Name: "slskd",
|
|
Enabled: true,
|
|
Settings: map[string]string{
|
|
"url": stub.server.URL,
|
|
"downloadsPath": downloads,
|
|
},
|
|
},
|
|
func(string) (string, error) { return "test-key", nil },
|
|
slogDiscard(),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("newSlskd: %v", err)
|
|
}
|
|
|
|
s, ok := p.(*slskd)
|
|
if !ok {
|
|
t.Fatalf("provider is %T, want *slskd", p)
|
|
}
|
|
|
|
// Real intervals are tuned for Soulseek's pace; tests only care
|
|
// about the state machine, so run it at full speed.
|
|
s.searchPoll = time.Millisecond
|
|
s.searchWait = 200 * time.Millisecond
|
|
s.transferPoll = time.Millisecond
|
|
|
|
// Long enough that no existing test trips them by accident; the
|
|
// tests about stalls and absences set their own.
|
|
s.stallAfter = time.Minute
|
|
s.absentGrace = time.Minute
|
|
|
|
return s, downloads
|
|
}
|
|
|
|
func TestSlskdCheck(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
if err := s.Check(context.Background()); err != nil {
|
|
t.Errorf("Check: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSlskdCheckRejectsBadKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.unauthorized = true
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
if err := s.Check(context.Background()); !errors.Is(err, ErrSlskdAuth) {
|
|
t.Errorf("error = %v, want ErrSlskdAuth", err)
|
|
}
|
|
}
|
|
|
|
// A downloads folder that is not readable from this machine is the
|
|
// classic slskd-on-a-NAS misconfiguration, and must surface at
|
|
// configuration time rather than after a long transfer.
|
|
func TestSlskdCheckRejectsUnreadableDownloadsPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
|
|
p, err := newSlskd(
|
|
Config{
|
|
ID: 1,
|
|
Settings: map[string]string{
|
|
"url": stub.server.URL,
|
|
"downloadsPath": "/definitely/not/a/real/path",
|
|
},
|
|
},
|
|
func(string) (string, error) { return "test-key", nil },
|
|
slogDiscard(),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("newSlskd: %v", err)
|
|
}
|
|
|
|
if err := p.Check(context.Background()); !errors.Is(
|
|
err, ErrSlskdDownloadsPath,
|
|
) {
|
|
t.Errorf("error = %v, want ErrSlskdDownloadsPath", err)
|
|
}
|
|
}
|
|
|
|
// Soulseek has no album concept, so candidates are built by grouping a
|
|
// peer's files into the folders they live in.
|
|
func TestSlskdGroupsResultsByPeerAndFolder(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.responses = []slskdResponse{
|
|
{
|
|
Username: "peer-one",
|
|
HasFreeUploadSlot: true,
|
|
QueueLength: 0,
|
|
UploadSpeed: 2_000_000,
|
|
Files: []slskdFile{
|
|
{Filename: `@@x\Music\OK Computer\01 Airbag.flac`, Size: 30_000_000},
|
|
{Filename: `@@x\Music\OK Computer\02 Paranoid Android.flac`, Size: 40_000_000},
|
|
{Filename: `@@x\Music\Kid A\01 Everything.flac`, Size: 30_000_000},
|
|
{Filename: `@@x\Music\Kid A\02 Kid A.flac`, Size: 30_000_000},
|
|
},
|
|
},
|
|
{
|
|
Username: "peer-two",
|
|
HasFreeUploadSlot: false,
|
|
QueueLength: 40,
|
|
Files: []slskdFile{
|
|
{
|
|
Filename: `\share\OK Computer [320]\01 - Airbag.mp3`,
|
|
Size: 8_000_000,
|
|
BitRate: 320,
|
|
},
|
|
{
|
|
Filename: `\share\OK Computer [320]\02 - Paranoid Android.mp3`,
|
|
Size: 9_000_000,
|
|
BitRate: 320,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
got, err := s.Search(context.Background(), Download{
|
|
Artist: "Radiohead",
|
|
Album: "OK Computer",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
|
|
// Two folders from peer-one, one from peer-two.
|
|
if len(got) != 3 {
|
|
t.Fatalf("got %d candidates, want 3", len(got))
|
|
}
|
|
|
|
byOrigin := map[string]int{}
|
|
for _, c := range got {
|
|
byOrigin[c.Origin]++
|
|
}
|
|
|
|
if byOrigin["peer-one"] != 2 {
|
|
t.Errorf("peer-one folders = %d, want 2", byOrigin["peer-one"])
|
|
}
|
|
|
|
if byOrigin["peer-two"] != 1 {
|
|
t.Errorf("peer-two folders = %d, want 1", byOrigin["peer-two"])
|
|
}
|
|
}
|
|
|
|
// A busy peer behind a long queue is a worse bet than a free one, no
|
|
// matter how good the files look.
|
|
func TestSlskdPeerHealthReflectsAvailability(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
free := peerHealth(slskdResponse{
|
|
HasFreeUploadSlot: true,
|
|
QueueLength: 0,
|
|
UploadSpeed: 2_000_000,
|
|
})
|
|
|
|
busy := peerHealth(slskdResponse{
|
|
HasFreeUploadSlot: false,
|
|
QueueLength: 40,
|
|
})
|
|
|
|
if free <= busy {
|
|
t.Errorf("free peer health %f should exceed busy peer %f", free, busy)
|
|
}
|
|
|
|
if free > 1 || busy < 0 {
|
|
t.Errorf("health out of range: free=%f busy=%f", free, busy)
|
|
}
|
|
}
|
|
|
|
// Folders with almost nothing in them are Soulseek noise, not albums.
|
|
func TestSlskdSkipsTinyFolders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.responses = []slskdResponse{{
|
|
Username: "peer",
|
|
Files: []slskdFile{
|
|
{Filename: `\share\Random\one.mp3`, Size: 5_000_000},
|
|
},
|
|
}}
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
got, err := s.Search(context.Background(), Download{Query: "x"})
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
|
|
if len(got) != 0 {
|
|
t.Errorf("got %d candidates, want 0 for a single-file folder", len(got))
|
|
}
|
|
}
|
|
|
|
func TestSlskdGrabCollectsFromDownloadsFolder(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.transfers = [][]slskdTransfer{
|
|
{
|
|
{
|
|
Filename: `\share\OK Computer\01 Airbag.flac`,
|
|
State: "InProgress",
|
|
BytesTransferred: 100,
|
|
},
|
|
{
|
|
Filename: `\share\OK Computer\02 Paranoid Android.flac`,
|
|
State: "InProgress",
|
|
},
|
|
},
|
|
{
|
|
{
|
|
Filename: `\share\OK Computer\01 Airbag.flac`,
|
|
State: "Completed, Succeeded",
|
|
BytesTransferred: 500,
|
|
},
|
|
{
|
|
Filename: `\share\OK Computer\02 Paranoid Android.flac`,
|
|
State: "Completed, Succeeded",
|
|
BytesTransferred: 500,
|
|
},
|
|
},
|
|
}
|
|
|
|
s, downloads := newStubSlskd(t, stub)
|
|
|
|
// slskd writes into <downloads>/<folder>/<file>.
|
|
folder := filepath.Join(downloads, "OK Computer")
|
|
if err := os.MkdirAll(folder, 0o750); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
|
|
for _, name := range []string{
|
|
"01 Airbag.flac",
|
|
"02 Paranoid Android.flac",
|
|
} {
|
|
if err := os.WriteFile(
|
|
filepath.Join(folder, name), []byte("audio"), 0o600,
|
|
); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
}
|
|
|
|
c := Candidate{
|
|
ID: "slskd:peer:OK Computer",
|
|
Protocol: ProtocolDirect,
|
|
Files: []CandidateFile{
|
|
{Path: `\share\OK Computer\01 Airbag.flac`, Size: 500, IsAudio: true},
|
|
{Path: `\share\OK Computer\02 Paranoid Android.flac`, Size: 500, IsAudio: true},
|
|
},
|
|
TotalSize: 1000,
|
|
Payload: map[string]string{"username": "peer"},
|
|
}
|
|
|
|
dst := t.TempDir()
|
|
|
|
got, err := s.Grab(context.Background(), c, dst, nil)
|
|
if err != nil {
|
|
t.Fatalf("Grab: %v", err)
|
|
}
|
|
|
|
if len(got.Files) != 2 {
|
|
t.Fatalf("collected %d files, want 2", len(got.Files))
|
|
}
|
|
|
|
for _, f := range got.Files {
|
|
if !strings.HasPrefix(f, dst) {
|
|
t.Errorf("file %s is not inside the staging dir %s", f, dst)
|
|
}
|
|
|
|
if _, err := os.Stat(f); err != nil {
|
|
t.Errorf("collected file missing: %v", err)
|
|
}
|
|
}
|
|
|
|
// The enqueue request named the files the candidate listed.
|
|
stub.mu.Lock()
|
|
enqueued := len(stub.enqueued)
|
|
stub.mu.Unlock()
|
|
|
|
if enqueued != 2 {
|
|
t.Errorf("enqueued %d files, want 2", enqueued)
|
|
}
|
|
}
|
|
|
|
// A peer that drops mid-folder is normal; partial results go forward
|
|
// and the importer's completeness check decides.
|
|
func TestSlskdGrabToleratesPartialFailure(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.transfers = [][]slskdTransfer{{
|
|
{
|
|
Filename: `\s\Album\01 A.flac`,
|
|
State: "Completed, Succeeded",
|
|
BytesTransferred: 500,
|
|
},
|
|
{Filename: `\s\Album\02 B.flac`, State: "Completed, Errored"},
|
|
}}
|
|
|
|
s, downloads := newStubSlskd(t, stub)
|
|
|
|
folder := filepath.Join(downloads, "Album")
|
|
if err := os.MkdirAll(folder, 0o750); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
|
|
if err := os.WriteFile(
|
|
filepath.Join(folder, "01 A.flac"), []byte("audio"), 0o600,
|
|
); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
c := Candidate{
|
|
Files: []CandidateFile{
|
|
{Path: `\s\Album\01 A.flac`, Size: 500, IsAudio: true},
|
|
{Path: `\s\Album\02 B.flac`, Size: 500, IsAudio: true},
|
|
},
|
|
Payload: map[string]string{"username": "peer"},
|
|
}
|
|
|
|
got, err := s.Grab(context.Background(), c, t.TempDir(), nil)
|
|
if err != nil {
|
|
t.Fatalf("Grab: %v", err)
|
|
}
|
|
|
|
if len(got.Files) != 1 {
|
|
t.Errorf("collected %d files, want the 1 that succeeded", len(got.Files))
|
|
}
|
|
}
|
|
|
|
func TestSlskdGrabFailsWhenEverythingFails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.transfers = [][]slskdTransfer{{
|
|
{Filename: `\s\Album\01 A.flac`, State: "Completed, Errored"},
|
|
}}
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
c := Candidate{
|
|
Files: []CandidateFile{{Path: `\s\Album\01 A.flac`, IsAudio: true}},
|
|
Payload: map[string]string{"username": "peer"},
|
|
}
|
|
|
|
_, err := s.Grab(context.Background(), c, t.TempDir(), nil)
|
|
if !errors.Is(err, ErrSlskdTransferFailed) {
|
|
t.Errorf("error = %v, want ErrSlskdTransferFailed", err)
|
|
}
|
|
}
|
|
|
|
func TestSlskdRequiresConfiguration(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
settings map[string]string
|
|
secrets SecretLookup
|
|
}{
|
|
{
|
|
name: "no url",
|
|
settings: map[string]string{"downloadsPath": "/tmp"},
|
|
secrets: func(string) (string, error) { return "k", nil },
|
|
},
|
|
{
|
|
name: "no downloads path",
|
|
settings: map[string]string{"url": "http://localhost:5030"},
|
|
secrets: func(string) (string, error) { return "k", nil },
|
|
},
|
|
{
|
|
name: "no api key",
|
|
settings: map[string]string{
|
|
"url": "http://localhost:5030", "downloadsPath": "/tmp",
|
|
},
|
|
secrets: func(string) (string, error) {
|
|
return "", ErrSecretNotFound
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := newSlskd(
|
|
Config{Settings: tt.settings}, tt.secrets, slogDiscard(),
|
|
)
|
|
|
|
if !errors.Is(err, ErrNotConfigured) {
|
|
t.Errorf("error = %v, want ErrNotConfigured", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// slskdAlbum is a two-file candidate from peer, with the files slskd
|
|
// would have written already in place under downloads.
|
|
func slskdAlbum(t *testing.T, downloads, peer string, arrived ...string) Candidate {
|
|
t.Helper()
|
|
|
|
folder := filepath.Join(downloads, "Album")
|
|
if err := os.MkdirAll(folder, 0o750); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
|
|
for _, name := range arrived {
|
|
if err := os.WriteFile(
|
|
filepath.Join(folder, name), []byte("audio"), 0o600,
|
|
); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
}
|
|
|
|
return Candidate{
|
|
Files: []CandidateFile{
|
|
{Path: `\s\Album\01 A.flac`, Size: 500, IsAudio: true},
|
|
{Path: `\s\Album\02 B.flac`, Size: 500, IsAudio: true},
|
|
},
|
|
TotalSize: 1000,
|
|
Payload: map[string]string{"username": peer},
|
|
}
|
|
}
|
|
|
|
// cancelledURIs returns what the stub was asked to cancel.
|
|
func (s *slskdStub) cancelledURIs() []string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return append([]string(nil), s.cancelled...)
|
|
}
|
|
|
|
// A peer that queues us and never sends a byte is given up on, and the
|
|
// queued transfers are cancelled in slskd rather than left to start
|
|
// hours later for a request nobody is waiting on.
|
|
func TestSlskdGrabGivesUpOnAStalledPeer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.transfers = [][]slskdTransfer{{
|
|
{ID: "t1", Filename: `\s\Album\01 A.flac`, State: "Queued, Remotely"},
|
|
{ID: "t2", Filename: `\s\Album\02 B.flac`, State: "Queued, Remotely"},
|
|
}}
|
|
|
|
s, downloads := newStubSlskd(t, stub)
|
|
s.stallAfter = 30 * time.Millisecond
|
|
|
|
_, err := s.Grab(
|
|
context.Background(), slskdAlbum(t, downloads, "peer"), t.TempDir(), nil,
|
|
)
|
|
if !errors.Is(err, ErrSlskdTimeout) {
|
|
t.Fatalf("error = %v, want ErrSlskdTimeout", err)
|
|
}
|
|
|
|
got := stub.cancelledURIs()
|
|
if len(got) != 2 {
|
|
t.Fatalf("cancelled %v, want both queued transfers", got)
|
|
}
|
|
|
|
for _, uri := range got {
|
|
if !strings.HasSuffix(uri, "?remove=true") {
|
|
t.Errorf("cancel %s does not remove the record", uri)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A folder that stalls on its last track goes forward with what
|
|
// arrived, the same as one whose last track failed; the importer's
|
|
// completeness check decides whether that is enough.
|
|
func TestSlskdGrabKeepsWhatArrivedBeforeAStall(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.transfers = [][]slskdTransfer{{
|
|
{
|
|
ID: "t1", Filename: `\s\Album\01 A.flac`,
|
|
State: "Completed, Succeeded", BytesTransferred: 500,
|
|
},
|
|
{ID: "t2", Filename: `\s\Album\02 B.flac`, State: "Queued, Remotely"},
|
|
}}
|
|
|
|
s, downloads := newStubSlskd(t, stub)
|
|
s.stallAfter = 30 * time.Millisecond
|
|
|
|
got, err := s.Grab(
|
|
context.Background(),
|
|
slskdAlbum(t, downloads, "peer", "01 A.flac"),
|
|
t.TempDir(), nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Grab: %v", err)
|
|
}
|
|
|
|
if len(got.Files) != 1 {
|
|
t.Errorf("collected %d files, want the 1 that arrived", len(got.Files))
|
|
}
|
|
|
|
if cancelled := stub.cancelledURIs(); len(cancelled) != 1 ||
|
|
!strings.Contains(cancelled[0], "/t2") {
|
|
t.Errorf("cancelled %v, want only the stalled t2", cancelled)
|
|
}
|
|
}
|
|
|
|
// Progress is what holds the stall timer off. A transfer that keeps
|
|
// moving bytes is never abandoned, however long it takes.
|
|
func TestSlskdGrabWaitsOnATransferThatIsMoving(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
|
|
for b := int64(1); b <= 100; b++ {
|
|
stub.transfers = append(stub.transfers, []slskdTransfer{
|
|
{ID: "t1", Filename: `\s\Album\01 A.flac`, State: "InProgress", BytesTransferred: b},
|
|
{ID: "t2", Filename: `\s\Album\02 B.flac`, State: "Queued, Remotely"},
|
|
})
|
|
}
|
|
|
|
stub.transfers = append(stub.transfers, []slskdTransfer{
|
|
{
|
|
ID: "t1",
|
|
Filename: `\s\Album\01 A.flac`,
|
|
State: "Completed, Succeeded",
|
|
BytesTransferred: 500,
|
|
},
|
|
{
|
|
ID: "t2",
|
|
Filename: `\s\Album\02 B.flac`,
|
|
State: "Completed, Succeeded",
|
|
BytesTransferred: 500,
|
|
},
|
|
})
|
|
|
|
s, downloads := newStubSlskd(t, stub)
|
|
// A hundred polls take several times the stall window; each one
|
|
// moves a byte. The window is kept well above one poll so a
|
|
// descheduled test runner does not read as a stall.
|
|
s.transferPoll = 5 * time.Millisecond
|
|
s.stallAfter = 150 * time.Millisecond
|
|
|
|
got, err := s.Grab(
|
|
context.Background(),
|
|
slskdAlbum(t, downloads, "peer", "01 A.flac", "02 B.flac"),
|
|
t.TempDir(), nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Grab: %v", err)
|
|
}
|
|
|
|
if len(got.Files) != 2 {
|
|
t.Errorf("collected %d files, want 2", len(got.Files))
|
|
}
|
|
}
|
|
|
|
// A file slskd never lists was refused at enqueue and will never reach
|
|
// a terminal state. It counts as failed once the grace period is up,
|
|
// rather than being waited on until the six-hour ceiling.
|
|
func TestSlskdGrabCountsAnUnlistedFileAsFailed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.transfers = [][]slskdTransfer{{
|
|
{
|
|
ID: "t1", Filename: `\s\Album\01 A.flac`,
|
|
State: "Completed, Succeeded", BytesTransferred: 500,
|
|
},
|
|
}}
|
|
|
|
s, downloads := newStubSlskd(t, stub)
|
|
s.absentGrace = 20 * time.Millisecond
|
|
|
|
got, err := s.Grab(
|
|
context.Background(),
|
|
slskdAlbum(t, downloads, "peer", "01 A.flac"),
|
|
t.TempDir(), nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Grab: %v", err)
|
|
}
|
|
|
|
if len(got.Files) != 1 {
|
|
t.Errorf("collected %d files, want 1", len(got.Files))
|
|
}
|
|
}
|
|
|
|
// A finished record left by an earlier attempt at the same file is not
|
|
// this attempt's answer. Without the snapshot it would fail the grab on
|
|
// the first poll, before the new transfer had started.
|
|
func TestSlskdGrabIgnoresAnEarlierAttemptsRecord(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stale := slskdTransfer{
|
|
ID: "old", Filename: `\s\Album\01 A.flac`, State: "Completed, Errored",
|
|
}
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.before = []slskdTransfer{stale}
|
|
stub.transfers = [][]slskdTransfer{
|
|
{stale},
|
|
{
|
|
stale,
|
|
{
|
|
ID: "new", Filename: `\s\Album\01 A.flac`,
|
|
State: "Completed, Succeeded", BytesTransferred: 500,
|
|
},
|
|
},
|
|
}
|
|
|
|
s, downloads := newStubSlskd(t, stub)
|
|
|
|
c := slskdAlbum(t, downloads, "peer", "01 A.flac")
|
|
c.Files = c.Files[:1]
|
|
|
|
got, err := s.Grab(context.Background(), c, t.TempDir(), nil)
|
|
if err != nil {
|
|
t.Fatalf("Grab: %v", err)
|
|
}
|
|
|
|
if len(got.Files) != 1 {
|
|
t.Errorf("collected %d files, want 1", len(got.Files))
|
|
}
|
|
}
|
|
|
|
// Cancelling the download cancels the transfer in slskd too. The
|
|
// cleanup must not inherit the cancelled context, or it is never sent.
|
|
func TestSlskdGrabCancelsTransfersWhenTheCallerGivesUp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.transfers = [][]slskdTransfer{{
|
|
{ID: "t1", Filename: `\s\Album\01 A.flac`, State: "InProgress", BytesTransferred: 10},
|
|
{ID: "t2", Filename: `\s\Album\02 B.flac`, State: "Queued, Remotely"},
|
|
}}
|
|
|
|
s, downloads := newStubSlskd(t, stub)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
|
|
_, err := s.Grab(ctx, slskdAlbum(t, downloads, "peer"), t.TempDir(), nil)
|
|
if !errors.Is(err, ErrSlskdTimeout) {
|
|
t.Fatalf("error = %v, want ErrSlskdTimeout", err)
|
|
}
|
|
|
|
if got := stub.cancelledURIs(); len(got) != 2 {
|
|
t.Errorf("cancelled %v, want both live transfers", got)
|
|
}
|
|
}
|
|
|
|
// Soulseek usernames carry spaces and punctuation; spliced raw into the
|
|
// path, a name with a slash addresses a different endpoint entirely.
|
|
func TestSlskdEscapesTheUsername(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.transfers = [][]slskdTransfer{{
|
|
{
|
|
ID: "t1", Filename: `\s\Album\01 A.flac`,
|
|
State: "Completed, Succeeded", BytesTransferred: 500,
|
|
},
|
|
{
|
|
ID: "t2", Filename: `\s\Album\02 B.flac`,
|
|
State: "Completed, Succeeded", BytesTransferred: 500,
|
|
},
|
|
}}
|
|
|
|
s, downloads := newStubSlskd(t, stub)
|
|
|
|
if _, err := s.Grab(
|
|
context.Background(),
|
|
slskdAlbum(t, downloads, "dj a/b", "01 A.flac", "02 B.flac"),
|
|
t.TempDir(), nil,
|
|
); err != nil {
|
|
t.Fatalf("Grab: %v", err)
|
|
}
|
|
|
|
stub.mu.Lock()
|
|
paths := append([]string(nil), stub.paths...)
|
|
stub.mu.Unlock()
|
|
|
|
for _, p := range paths {
|
|
if p != "/api/v0/transfers/downloads/dj%20a%2Fb" {
|
|
t.Errorf("transfers call went to %s", p)
|
|
}
|
|
}
|
|
}
|