Checked against slskd 0.26.0's source rather than a live daemon, which #267 never had. slskd reads a search's searchTimeout in seconds, counted from the last response. We sent milliseconds, telling it a search may idle for five hours, so a search never completed on its own. It now sends seconds, with slskd's floor of 5. slskd writes a finished file to <downloads>/<remote leaf folder>/, and when a name is taken it writes name_<ticks>.ext beside it. collect found files by name there, so a file left by an earlier failed attempt, or by the user's own download, was collected in place of this grab's. A user who changed slskd's destination setting got nothing collected at all. slskd 0.26 takes a batch download with an explicit destination. Each grab now enqueues batches into yellowjacket/<uuid>/ (one per disc, since a batch's files land flat), collects from exactly there, and removes the folder afterwards, including after a failure. An older daemon answers the batch route with 400, which is remembered, and the per-user enqueue is used. There, collect skips files that were already present, unchanged, before the enqueue, and takes the renamed copy slskd wrote instead. The comparison is against a snapshot, not a clock, because slskd may run on another machine. The per-folder lock from #272 is kept only while batches are not known to work. The test stub now writes files when they are enqueued, as slskd does, including the rename, so tests no longer stage files before a grab. An opt-in TestSlskdLive runs against a real daemon when YJ_SLSKD_URL, YJ_SLSKD_API_KEY and YJ_SLSKD_DOWNLOADS are set. Closes #274 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT
329 lines
8.2 KiB
Go
329 lines
8.2 KiB
Go
package download
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Where slskd writes a grab's files, and how collect finds them (#274).
|
|
|
|
// slskd reads searchTimeout in whole seconds, from the last response.
|
|
func TestSlskdSearchTimeoutIsInSeconds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
wait time.Duration
|
|
want int
|
|
}{
|
|
{20 * time.Second, 18},
|
|
{200 * time.Millisecond, slskdMinSearchTimeout},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
s := &slskd{searchWait: tc.wait}
|
|
|
|
got, ok := s.searchRequest("id", "text", 2)["searchTimeout"].(int)
|
|
if !ok || got != tc.want {
|
|
t.Errorf("wait %s: searchTimeout = %v, want %d seconds", tc.wait, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsRenamedCopy(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := map[string]bool{
|
|
"01 A_638912345678901234.flac": true,
|
|
"01 A.flac": false,
|
|
"01 A_.flac": false,
|
|
"01 A_v2.flac": false,
|
|
"01 A_123.mp3": false,
|
|
"01 AB_123.flac": false,
|
|
}
|
|
|
|
for name, want := range cases {
|
|
if got := isRenamedCopy(name, "01 A", ".flac"); got != want {
|
|
t.Errorf("isRenamedCopy(%q) = %v, want %v", name, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func succeeded(names ...string) [][]slskdTransfer {
|
|
out := make([]slskdTransfer, 0, len(names))
|
|
for i, n := range names {
|
|
out = append(out, slskdTransfer{
|
|
ID: "t" + itoa(i),
|
|
Filename: n,
|
|
State: "Completed, Succeeded",
|
|
BytesTransferred: 500,
|
|
})
|
|
}
|
|
|
|
return [][]slskdTransfer{out}
|
|
}
|
|
|
|
// On a daemon with batches, each grab writes into a folder of its own,
|
|
// collect reads from exactly there, and the folder is gone afterwards.
|
|
func TestSlskdBatchGrabUsesItsOwnFolder(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.batches = true
|
|
stub.transfers = succeeded(`\s\Album\01 A.flac`, `\s\Album\02 B.flac`)
|
|
|
|
s, downloads := newStubSlskd(t, stub)
|
|
|
|
// A same-named file in the folder a per-user enqueue would use is
|
|
// someone else's, and must not be touched.
|
|
other := filepath.Join(downloads, "Album", "01 A.flac")
|
|
if err := os.MkdirAll(filepath.Dir(other), 0o750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := os.WriteFile(other, []byte("the user's"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dst := t.TempDir()
|
|
|
|
got, err := s.Grab(
|
|
context.Background(), slskdAlbum(t, stub, "peer", "01 A.flac", "02 B.flac"), 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))
|
|
}
|
|
|
|
stub.mu.Lock()
|
|
bodies := append([]map[string]any(nil), stub.batchBodies...)
|
|
stub.mu.Unlock()
|
|
|
|
if len(bodies) != 1 {
|
|
t.Fatalf("%d batches, want 1", len(bodies))
|
|
}
|
|
|
|
if bodies[0]["username"] != "peer" {
|
|
t.Errorf("batch username = %v", bodies[0]["username"])
|
|
}
|
|
|
|
dest, _ := bodies[0]["options"].(map[string]any)["destination"].(string)
|
|
if !strings.HasPrefix(dest, slskdDestRoot+"/") {
|
|
t.Errorf("destination %q is not under %s", dest, slskdDestRoot)
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Join(downloads, filepath.FromSlash(dest))); !os.IsNotExist(err) {
|
|
t.Errorf("batch folder left behind: %v", err)
|
|
}
|
|
|
|
if data, _ := os.ReadFile(other); string(data) != "the user's" {
|
|
t.Errorf("the user's own file was taken or changed: %q", data)
|
|
}
|
|
}
|
|
|
|
// A batch's files land flat in its destination, so a two-disc rip is
|
|
// two batches, one per disc, or disc 2's "01" is renamed out of the way
|
|
// of disc 1's.
|
|
func TestSlskdBatchSplitsDiscs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.batches = true
|
|
stub.transfers = succeeded(`\s\Wall\CD1\01 In.flac`, `\s\Wall\CD2\01 Hey You.flac`)
|
|
stub.deliver("01 In.flac", "01 Hey You.flac")
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
dst := t.TempDir()
|
|
|
|
got, err := s.Grab(context.Background(), Candidate{
|
|
Files: []CandidateFile{
|
|
{Path: `\s\Wall\CD1\01 In.flac`, Size: 500, IsAudio: true},
|
|
{Path: `\s\Wall\CD2\01 Hey You.flac`, Size: 500, IsAudio: true},
|
|
},
|
|
Payload: map[string]string{"username": "peer"},
|
|
}, dst, nil)
|
|
if err != nil {
|
|
t.Fatalf("Grab: %v", err)
|
|
}
|
|
|
|
stub.mu.Lock()
|
|
n := len(stub.batchBodies)
|
|
stub.mu.Unlock()
|
|
|
|
if n != 2 {
|
|
t.Errorf("%d batches, want one per disc", n)
|
|
}
|
|
|
|
for _, want := range []string{
|
|
filepath.Join(dst, "CD1", "01 In.flac"),
|
|
filepath.Join(dst, "CD2", "01 Hey You.flac"),
|
|
} {
|
|
found := false
|
|
|
|
for _, f := range got.Files {
|
|
found = found || f == want
|
|
}
|
|
|
|
if !found {
|
|
t.Errorf("%s not collected; got %q", want, got.Files)
|
|
}
|
|
}
|
|
}
|
|
|
|
// An abandoned batch grab leaves nothing in slskd's folder either.
|
|
func TestSlskdBatchFailureDiscardsItsFolder(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.batches = true
|
|
stub.transfers = [][]slskdTransfer{{
|
|
{ID: "t0", Filename: `\s\Album\01 A.flac`, State: "Completed, Errored"},
|
|
{ID: "t1", Filename: `\s\Album\02 B.flac`, State: "Completed, Errored"},
|
|
}}
|
|
|
|
s, downloads := newStubSlskd(t, stub)
|
|
|
|
// The stub delivers the file, as a partial slskd left behind would.
|
|
if _, err := s.Grab(
|
|
context.Background(), slskdAlbum(t, stub, "peer", "01 A.flac"), t.TempDir(), nil,
|
|
); err == nil {
|
|
t.Fatal("Grab succeeded with every transfer failed")
|
|
}
|
|
|
|
entries, _ := os.ReadDir(filepath.Join(downloads, slskdDestRoot))
|
|
if len(entries) != 0 {
|
|
t.Errorf("%d batch folders left behind", len(entries))
|
|
}
|
|
}
|
|
|
|
// An older daemon answers the batch endpoint with 400; the grab falls
|
|
// back to the per-user enqueue, and later grabs do not ask again.
|
|
func TestSlskdFallsBackWithoutBatches(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.transfers = succeeded(`\s\Album\01 A.flac`, `\s\Album\02 B.flac`)
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
for range 2 {
|
|
stub.mu.Lock()
|
|
stub.posted = false
|
|
stub.pollCount = 0
|
|
stub.mu.Unlock()
|
|
|
|
if _, err := s.Grab(
|
|
context.Background(), slskdAlbum(t, stub, "peer", "01 A.flac"), t.TempDir(), nil,
|
|
); err != nil {
|
|
t.Fatalf("Grab: %v", err)
|
|
}
|
|
}
|
|
|
|
stub.mu.Lock()
|
|
paths := append([]string(nil), stub.paths...)
|
|
stub.mu.Unlock()
|
|
|
|
batchCalls := 0
|
|
|
|
for _, p := range paths {
|
|
if strings.HasSuffix(p, "/batches") {
|
|
batchCalls++
|
|
}
|
|
}
|
|
|
|
if batchCalls != 1 {
|
|
t.Errorf("asked for a batch %d times, want once", batchCalls)
|
|
}
|
|
}
|
|
|
|
// Without batches, a file of the same name already in slskd's folder is
|
|
// not this grab's: slskd wrote ours beside it as name_<ticks>.ext, and
|
|
// that is the one collected.
|
|
func TestSlskdCollectsTheRenamedCopyNotTheOldFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.transfers = succeeded(`\s\Album\01 A.flac`, `\s\Album\02 B.flac`)
|
|
|
|
s, downloads := newStubSlskd(t, stub)
|
|
|
|
old := filepath.Join(downloads, "Album", "01 A.flac")
|
|
if err := os.MkdirAll(filepath.Dir(old), 0o750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := os.WriteFile(old, []byte("left by an earlier attempt"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dst := t.TempDir()
|
|
|
|
got, err := s.Grab(
|
|
context.Background(), slskdAlbum(t, stub, "peer", "01 A.flac", "02 B.flac"), 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))
|
|
}
|
|
|
|
data, err := os.ReadFile(filepath.Join(dst, "01 A.flac"))
|
|
if err != nil || string(data) != "audio" {
|
|
t.Errorf("collected %q, want this grab's file", data)
|
|
}
|
|
|
|
if data, _ := os.ReadFile(old); string(data) != "left by an earlier attempt" {
|
|
t.Error("the file that was already there was moved")
|
|
}
|
|
}
|
|
|
|
// And when this grab's copy never arrived, the old one is not taken in
|
|
// its place.
|
|
func TestSlskdDoesNotCollectAFileThatWasAlreadyThere(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.transfers = [][]slskdTransfer{
|
|
{
|
|
{ID: "t0", Filename: `\s\Album\01 A.flac`, State: "Completed, Errored"},
|
|
{
|
|
ID: "t1",
|
|
Filename: `\s\Album\02 B.flac`,
|
|
State: "Completed, Succeeded",
|
|
BytesTransferred: 500,
|
|
},
|
|
},
|
|
}
|
|
|
|
s, downloads := newStubSlskd(t, stub)
|
|
|
|
old := filepath.Join(downloads, "Album", "01 A.flac")
|
|
if err := os.MkdirAll(filepath.Dir(old), 0o750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := os.WriteFile(old, []byte("stale"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := s.Grab(
|
|
context.Background(), slskdAlbum(t, stub, "peer", "02 B.flac"), t.TempDir(), nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Grab: %v", err)
|
|
}
|
|
|
|
if len(got.Files) != 1 || filepath.Base(got.Files[0]) != "02 B.flac" {
|
|
t.Errorf("collected %q, want only 02 B.flac", got.Files)
|
|
}
|
|
}
|