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
249 lines
6.9 KiB
Go
249 lines
6.9 KiB
Go
package download
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// Multi-disc rips, single-track results and coverage counted in tracks
|
|
// rather than files (#270).
|
|
|
|
func TestParsePathReadsTheDiscFromItsFolder(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
path string
|
|
disc int
|
|
track int
|
|
folder string
|
|
}{
|
|
{`\share\Pink Floyd - The Wall (1979)\CD2\03 Hey You.flac`, 2, 3, "The Wall"},
|
|
{`\share\The Wall\Disc 1\01 In The Flesh.flac`, 1, 1, "The Wall"},
|
|
{`\share\The Wall\[Disk-2]\01 Hey You.flac`, 2, 1, "The Wall"},
|
|
{`\share\The Wall\CD1 - Live\04 Mother.flac`, 1, 4, "The Wall"},
|
|
// The filename's own disc number is more specific than the folder.
|
|
{`\share\The Wall\CD1\2-05 Comfortably Numb.flac`, 2, 5, "The Wall"},
|
|
// Not a disc folder: a number is required.
|
|
{`\share\CDs\The Wall\01 In The Flesh.flac`, 0, 1, "The Wall"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.path, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := ParsePath(tc.path)
|
|
if got.Disc != tc.disc || got.Track != tc.track || got.Folder != tc.folder {
|
|
t.Errorf(
|
|
"ParsePath = disc %d track %d folder %q, want %d %d %q",
|
|
got.Disc, got.Track, got.Folder, tc.disc, tc.track, tc.folder,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAlbumDir(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := map[string]string{
|
|
`\share\Album\CD1\01 A.flac`: "/share/Album",
|
|
`\share\Album\01 A.flac`: "/share/Album",
|
|
`CD1\01 A.flac`: "CD1",
|
|
`\share\CD Collection\01.mp3`: "/share/CD Collection",
|
|
}
|
|
|
|
for in, want := range cases {
|
|
if got := AlbumDir(in); got != want {
|
|
t.Errorf("AlbumDir(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// One album shared as CD1/CD2 is one candidate, named after the album.
|
|
func TestSlskdGroupsDiscFoldersIntoOneCandidate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.responses = []slskdResponse{{
|
|
Username: "peer",
|
|
Files: []slskdFile{
|
|
{Filename: `\m\The Wall\CD1\01 In The Flesh.flac`, Size: 1},
|
|
{Filename: `\m\The Wall\CD1\02 The Thin Ice.flac`, Size: 1},
|
|
{Filename: `\m\The Wall\CD2\01 Hey You.flac`, Size: 1},
|
|
{Filename: `\m\The Wall\CD2\02 Is There Anybody Out There.flac`, Size: 1},
|
|
},
|
|
}}
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
got, err := s.Search(context.Background(), Download{Query: "the wall"})
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
|
|
if len(got) != 1 {
|
|
t.Fatalf("got %d candidates, want the two discs as one", len(got))
|
|
}
|
|
|
|
if got[0].Title != "The Wall" || len(got[0].Files) != 4 {
|
|
t.Errorf(
|
|
"candidate = %q with %d files, want \"The Wall\" with 4",
|
|
got[0].Title, len(got[0].Files),
|
|
)
|
|
}
|
|
}
|
|
|
|
// A track search matches one file per folder, so a single-track request
|
|
// must accept a one-file folder that an album request rightly drops.
|
|
func TestSlskdKeepsASingleFileForATrackRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.responses = []slskdResponse{{
|
|
Username: "peer",
|
|
Files: []slskdFile{
|
|
{Filename: `\m\OK Computer\02 Paranoid Android.flac`, Size: 1},
|
|
},
|
|
}}
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
track, err := s.Search(context.Background(), Download{
|
|
RecordingMBID: "rec-1", Artist: "Radiohead", Album: "Paranoid Android",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
|
|
if len(track) != 1 {
|
|
t.Errorf("track request: got %d candidates, want 1", len(track))
|
|
}
|
|
|
|
album, err := s.Search(context.Background(), Download{
|
|
ReleaseMBID: "rel-1", Artist: "Radiohead", Album: "OK Computer",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
|
|
if len(album) != 0 {
|
|
t.Errorf("album request: got %d candidates, want the one-file folder dropped", len(album))
|
|
}
|
|
}
|
|
|
|
// Two discs with a file of the same name both reach staging, each under
|
|
// its disc folder, where the importer reads the disc number from.
|
|
func TestSlskdCollectKeepsDiscFolders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
s, downloads := newStubSlskd(t, stub)
|
|
|
|
for _, disc := range []string{"CD1", "CD2"} {
|
|
dir := filepath.Join(downloads, disc)
|
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
|
|
if err := os.WriteFile(
|
|
filepath.Join(dir, "01 Intro.flac"), []byte(disc), 0o600,
|
|
); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
}
|
|
|
|
dst := t.TempDir()
|
|
|
|
got, err := s.collect(Candidate{Files: []CandidateFile{
|
|
{Path: `\m\Album\CD1\01 Intro.flac`, IsAudio: true},
|
|
{Path: `\m\Album\CD2\01 Intro.flac`, IsAudio: true},
|
|
}}, dst, "", nil)
|
|
if err != nil {
|
|
t.Fatalf("collect: %v", err)
|
|
}
|
|
|
|
if len(got.Files) != 2 {
|
|
t.Fatalf("collected %d files, want 2", len(got.Files))
|
|
}
|
|
|
|
for _, disc := range []string{"CD1", "CD2"} {
|
|
data, err := os.ReadFile(filepath.Join(dst, disc, "01 Intro.flac"))
|
|
if err != nil || string(data) != disc {
|
|
t.Errorf("%s's file missing or overwritten: %q, %v", disc, data, err)
|
|
}
|
|
|
|
if hint := ParsePath(filepath.Join(dst, disc, "01 Intro.flac")); hint.Disc == 0 {
|
|
t.Errorf("staged %s file lost its disc number", disc)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A two-disc release whose discs both number from 01 aligns completely
|
|
// once the disc comes from the folder; before, disc 2's 01 collided with
|
|
// disc 1's.
|
|
func TestMultiDiscCandidateAlignsEveryTrack(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dl := Download{
|
|
ReleaseMBID: "the-wall",
|
|
Artist: "Pink Floyd",
|
|
Album: "The Wall",
|
|
Expected: []ExpectedTrack{
|
|
{DiscNumber: 1, Position: 1, Title: "In the Flesh?"},
|
|
{DiscNumber: 1, Position: 2, Title: "The Thin Ice"},
|
|
{DiscNumber: 2, Position: 1, Title: "Hey You"},
|
|
{DiscNumber: 2, Position: 2, Title: "Is There Anybody Out There?"},
|
|
},
|
|
}
|
|
|
|
c := Candidate{
|
|
Title: "The Wall",
|
|
Files: []CandidateFile{
|
|
{Path: `\m\Pink Floyd - The Wall\CD1\01 In the Flesh.flac`, Size: 1},
|
|
{Path: `\m\Pink Floyd - The Wall\CD1\02 The Thin Ice.flac`, Size: 1},
|
|
{Path: `\m\Pink Floyd - The Wall\CD2\01 Hey You.flac`, Size: 1},
|
|
{Path: `\m\Pink Floyd - The Wall\CD2\02 Is There Anybody Out There.flac`, Size: 1},
|
|
},
|
|
}
|
|
|
|
got := Score(dl, c, 50, AutoDownloadPrefs{})
|
|
|
|
if got.Match.Completeness != 1 {
|
|
t.Errorf("completeness = %f, want 1", got.Match.Completeness)
|
|
}
|
|
|
|
if got.Match.AlbumFit < 0.99 {
|
|
t.Errorf("album fit = %f, want the album's own name to match", got.Match.AlbumFit)
|
|
}
|
|
|
|
if got.Match.Overall < minMatch {
|
|
t.Errorf("match = %f, want it to clear the auto-pick bar %f", got.Match.Overall, minMatch)
|
|
}
|
|
}
|
|
|
|
// Ten files against a ten-track album is not a complete album when only
|
|
// three of them are its tracks.
|
|
func TestCompletenessCountsTracksNotFiles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dl := okComputer()
|
|
|
|
c := Candidate{Title: "OK Computer", Files: []CandidateFile{
|
|
{Path: `\m\Radiohead - OK Computer\Airbag.flac`, Size: 1},
|
|
{Path: `\m\Radiohead - OK Computer\Paranoid Android.flac`, Size: 1},
|
|
{Path: `\m\Radiohead - OK Computer\Exit Music (For a Film).flac`, Size: 1},
|
|
{Path: `\m\Radiohead - OK Computer\Creep.flac`, Size: 1},
|
|
}}
|
|
|
|
got := Score(dl, c, 50, AutoDownloadPrefs{})
|
|
|
|
if got.Match.Completeness > 0.76 {
|
|
t.Errorf(
|
|
"completeness = %f with 3 of 4 tracks present, want at most 0.75",
|
|
got.Match.Completeness,
|
|
)
|
|
}
|
|
}
|