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
239 lines
6.2 KiB
Go
239 lines
6.2 KiB
Go
package download
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// What Soulseek is asked, how, and what is kept from the answer (#271).
|
|
|
|
func TestSlskdQueries(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
dl Download
|
|
want []string
|
|
}{
|
|
{
|
|
name: "a plain request is searched once",
|
|
dl: Download{Artist: "Radiohead", Album: "OK Computer"},
|
|
want: []string{"Radiohead OK Computer"},
|
|
},
|
|
{
|
|
name: "an edition qualifier gets a second query without it",
|
|
dl: Download{Artist: "Radiohead", Album: "OK Computer (Collector's Edition)"},
|
|
want: []string{
|
|
"Radiohead OK Computer (Collector's Edition)",
|
|
"Radiohead OK Computer",
|
|
},
|
|
},
|
|
{
|
|
name: "a trailing remaster note",
|
|
dl: Download{Artist: "Pink Floyd", Album: "Animals - 2018 Remaster"},
|
|
want: []string{
|
|
"Pink Floyd Animals - 2018 Remaster",
|
|
"Pink Floyd Animals",
|
|
},
|
|
},
|
|
{
|
|
name: "a leading dash would be an exclusion",
|
|
dl: Download{Artist: "Mocky", Album: "-ism"},
|
|
want: []string{"Mocky -ism", "Mocky ism"},
|
|
},
|
|
{
|
|
name: "a compilation is not searched by its placeholder artist",
|
|
dl: Download{Artist: "Various Artists", Album: "Pulp Fiction"},
|
|
want: []string{"Various Artists Pulp Fiction", "Pulp Fiction"},
|
|
},
|
|
{
|
|
name: "what the user typed is searched as written",
|
|
dl: Download{Query: "ok computer (deluxe)", Album: "OK Computer (Deluxe)"},
|
|
want: []string{"ok computer (deluxe)"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := slskdQueries(tc.dl)
|
|
if strings.Join(got, "|") != strings.Join(tc.want, "|") {
|
|
t.Errorf("slskdQueries = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Both queries run, the options are stated rather than left to the
|
|
// daemon's defaults, and a folder both queries found is one candidate.
|
|
func TestSlskdSearchRunsBothQueriesAndMerges(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.responses = []slskdResponse{{
|
|
Username: "peer",
|
|
Files: []slskdFile{
|
|
{Filename: `\m\Radiohead - OK Computer\01 Airbag.flac`, Size: 1, Length: 284},
|
|
{Filename: `\m\Radiohead - OK Computer\02 Paranoid Android.flac`, Size: 1, Length: 383},
|
|
},
|
|
}}
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
got, err := s.Search(context.Background(), Download{
|
|
ReleaseMBID: "rel", Artist: "Radiohead", Album: "OK Computer (Deluxe Edition)",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
|
|
if len(got) != 1 {
|
|
t.Fatalf("got %d candidates, want the one folder once", len(got))
|
|
}
|
|
|
|
if got[0].Files[0].LengthMillis != 284_000 {
|
|
t.Errorf("length = %d ms, want 284000 from slskd's seconds", got[0].Files[0].LengthMillis)
|
|
}
|
|
|
|
stub.mu.Lock()
|
|
searches := append([]map[string]any(nil), stub.searches...)
|
|
gets := append([]string(nil), stub.searchGets...)
|
|
stub.mu.Unlock()
|
|
|
|
if len(searches) != 2 {
|
|
t.Fatalf("ran %d searches, want 2", len(searches))
|
|
}
|
|
|
|
for _, body := range searches {
|
|
for _, key := range []string{
|
|
"searchTimeout", "responseLimit", "fileLimit",
|
|
"minimumResponseFileCount", "maximumPeerQueueLength",
|
|
} {
|
|
if _, ok := body[key]; !ok {
|
|
t.Errorf("search %q does not state %s", body["searchText"], key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The responses are fetched once at the end, not with every poll.
|
|
for _, uri := range gets {
|
|
if strings.Contains(uri, "includeResponses") {
|
|
t.Errorf("poll %s asked for every response", uri)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A daemon without the responses endpoint still returns results.
|
|
func TestSlskdSearchFallsBackForAnOlderDaemon(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newSlskdStub(t)
|
|
stub.noResponsesEndpoint = true
|
|
stub.responses = []slskdResponse{{
|
|
Username: "peer",
|
|
Files: []slskdFile{
|
|
{Filename: `\m\Album\01 A.flac`, Size: 1},
|
|
{Filename: `\m\Album\02 B.flac`, Size: 1},
|
|
},
|
|
}}
|
|
|
|
s, _ := newStubSlskd(t, stub)
|
|
|
|
got, err := s.Search(context.Background(), Download{Query: "album"})
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
|
|
if len(got) != 1 {
|
|
t.Errorf("got %d candidates, want 1 through the fallback", len(got))
|
|
}
|
|
}
|
|
|
|
func TestDurationAgreement(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
got, want int64
|
|
score float64
|
|
}{
|
|
{300_000, 300_000, 1},
|
|
{301_500, 300_000, 1}, // a second of silence
|
|
{300_000, 316_500, 0.5},
|
|
{300_000, 345_000, 0}, // a different edit
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
if got := durationAgreement(tc.got, tc.want); got < tc.score-0.01 || got > tc.score+0.01 {
|
|
t.Errorf("durationAgreement(%d, %d) = %f, want %f", tc.got, tc.want, got, tc.score)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Two folders with the same track names are told apart by their
|
|
// lengths: one is the album, the other a live record of the same songs.
|
|
func TestDurationsSeparateTheRightRecording(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dl := okComputer()
|
|
|
|
timed := func(id string, lengths ...int64) Candidate {
|
|
c := candidateFor(id, allTitles(), ".flac", 30_000_000)
|
|
for i := range c.Files {
|
|
c.Files[i].LengthMillis = lengths[i]
|
|
}
|
|
|
|
return c
|
|
}
|
|
|
|
studio := timed("studio", trackMillis, trackMillis+1_000, trackMillis, trackMillis-500)
|
|
live := timed(
|
|
"live",
|
|
trackMillis+60_000,
|
|
trackMillis+75_000,
|
|
trackMillis+50_000,
|
|
trackMillis+90_000,
|
|
)
|
|
|
|
ranked := Rank(dl, []Candidate{live, studio}, nil, AutoDownloadPrefs{})
|
|
|
|
if ranked[0].ID != "studio" {
|
|
t.Fatalf("winner = %s, want the recording whose lengths match", ranked[0].ID)
|
|
}
|
|
|
|
if !ranked[0].Match.DurationKnown || ranked[0].Match.DurationFit < 0.99 {
|
|
t.Errorf(
|
|
"studio duration fit = %f known=%v",
|
|
ranked[0].Match.DurationFit,
|
|
ranked[0].Match.DurationKnown,
|
|
)
|
|
}
|
|
|
|
if ranked[1].Match.DurationFit != 0 {
|
|
t.Errorf("live duration fit = %f, want 0", ranked[1].Match.DurationFit)
|
|
}
|
|
}
|
|
|
|
// Without lengths the score is exactly what it was before durations
|
|
// were read, so a provider that reports none is not penalised.
|
|
func TestUnknownDurationsLeaveTheScoreAlone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dl := okComputer()
|
|
c := Score(dl, candidateFor("c", allTitles(), ".flac", 30_000_000), 50, AutoDownloadPrefs{})
|
|
|
|
if c.Match.DurationKnown {
|
|
t.Fatal("no file states a length, yet durations are known")
|
|
}
|
|
|
|
want := weightTitleFit*c.Match.TitleFit +
|
|
weightCompleteness*c.Match.Completeness +
|
|
weightAlbumFit*c.Match.AlbumFit +
|
|
weightArtistFit*c.Match.ArtistFit
|
|
|
|
if c.Match.Overall != want {
|
|
t.Errorf("match = %f, want the untimed formula's %f", c.Match.Overall, want)
|
|
}
|
|
}
|