refactor(download): rename Want/Request to Request/Download, unify downloads flow, add auto-download guardrails
The durable "I asked for this" record was called Want, and the one-shot search-and-grab attempt was called Request — names that didn't match what either actually did. Want is now Request, and the old Request/Item is now Download/DownloadItem, with a table-rename migration (download_wants -> download_requests, old download_requests -> download_downloads) safe against both fresh installs and existing data. Every anchored manual download now upserts/reuses a durable Request before running, so a "download now" that finds nothing is picked up by the background reconciler automatically instead of just failing with no trace — the gap that caused this session's repeated "no candidates found" failures on the same album. Also adds auto-download guardrails (file-size min/max with a preferred target, allowed file types) that gate what the pipeline may grab unattended, live-editable from a new settings section. The frontend's wanted-view becomes downloads-view, with a new Downloads tab showing attempt/transfer history that previously had no UI at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newServiceFixture wires a Service over the same manager/store a
|
||||
// managerFixture uses, so a manual download can be started and watched
|
||||
// through to completion with no network anywhere.
|
||||
type serviceFixture struct {
|
||||
managerFixture
|
||||
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func newServiceFixture(t *testing.T) serviceFixture {
|
||||
t.Helper()
|
||||
|
||||
mf := newManagerFixture(t)
|
||||
svc := NewService(slogDiscard(), mf.manager, mf.store, NewMemSecretStore())
|
||||
|
||||
return serviceFixture{managerFixture: mf, svc: svc}
|
||||
}
|
||||
|
||||
// A manual download for something anchored by MBID must leave a
|
||||
// durable Request behind, whether or not the download itself succeeds
|
||||
// — that is the whole point of ensureRequest: a manual attempt that
|
||||
// finds nothing right now is not just lost, the reconciler picks it up
|
||||
// later on its normal schedule.
|
||||
func TestStartDownloadCreatesRequestForAnchoredDownload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newServiceFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
provider := fakeWithAlbum(1, "source", ".flac")
|
||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
||||
|
||||
dl := fourTrackDownload()
|
||||
|
||||
if _, err := f.svc.StartDownload(SearchRequest{
|
||||
LibraryID: 1,
|
||||
ReleaseGroupMBID: "rg-1",
|
||||
Artist: dl.Artist,
|
||||
Album: dl.Album,
|
||||
Expected: dl.Expected,
|
||||
}); err != nil {
|
||||
t.Fatalf("StartDownload: %v", err)
|
||||
}
|
||||
|
||||
req, found, err := f.store.FindRequest(ctx, "rg-1", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("FindRequest: %v", err)
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Fatal("manual anchored download did not create a durable request")
|
||||
}
|
||||
|
||||
if req.Entity != EntityReleaseGroup {
|
||||
t.Errorf("entity = %q, want release-group", req.Entity)
|
||||
}
|
||||
|
||||
if req.State != RequestStateWanted {
|
||||
t.Errorf("state = %q, want wanted", req.State)
|
||||
}
|
||||
}
|
||||
|
||||
// A free-text download (no MBID) has nothing stable to attach a
|
||||
// request to, and must not create one.
|
||||
func TestStartDownloadFreeTextCreatesNoRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newServiceFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
provider := fakeWithAlbum(1, "source", ".flac")
|
||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
||||
|
||||
if _, err := f.svc.StartDownload(SearchRequest{
|
||||
LibraryID: 1,
|
||||
Query: "some free text search",
|
||||
}); err != nil {
|
||||
t.Fatalf("StartDownload: %v", err)
|
||||
}
|
||||
|
||||
all, err := f.store.ListRequests(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRequests: %v", err)
|
||||
}
|
||||
|
||||
if len(all) != 0 {
|
||||
t.Errorf("free-text download created %d requests, want 0", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// A manual download must not un-pause a request the user deliberately
|
||||
// paused: it runs its one interactive attempt regardless, but the
|
||||
// request's own state is left alone.
|
||||
func TestStartDownloadDoesNotUnpauseExistingRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newServiceFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := f.store.AddRequest(ctx, Request{
|
||||
MBID: "rg-1",
|
||||
Entity: EntityReleaseGroup,
|
||||
LibraryID: 1,
|
||||
Artist: "Radiohead",
|
||||
Title: "OK Computer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddRequest: %v", err)
|
||||
}
|
||||
|
||||
if err := f.store.SetRequestState(
|
||||
ctx, id, RequestStatePaused, "",
|
||||
); err != nil {
|
||||
t.Fatalf("SetRequestState: %v", err)
|
||||
}
|
||||
|
||||
provider := fakeWithAlbum(1, "source", ".flac")
|
||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
||||
|
||||
dl := fourTrackDownload()
|
||||
|
||||
if _, err := f.svc.StartDownload(SearchRequest{
|
||||
LibraryID: 1,
|
||||
ReleaseGroupMBID: "rg-1",
|
||||
Artist: dl.Artist,
|
||||
Album: dl.Album,
|
||||
Expected: dl.Expected,
|
||||
}); err != nil {
|
||||
t.Fatalf("StartDownload: %v", err)
|
||||
}
|
||||
|
||||
req, err := f.store.GetRequest(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRequest: %v", err)
|
||||
}
|
||||
|
||||
if req.State != RequestStatePaused {
|
||||
t.Errorf(
|
||||
"a manual download un-paused the request: state = %q, want paused",
|
||||
req.State,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A manual download that clearly wins auto-pick still satisfies the
|
||||
// durable request it was attached to when it completes — the same
|
||||
// SatisfyRequest call the reconciler relies on.
|
||||
func TestManualDownloadSatisfiesRequestOnSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newServiceFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
provider := fakeWithAlbum(1, "source", ".flac")
|
||||
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
|
||||
|
||||
dl := fourTrackDownload()
|
||||
|
||||
result, err := f.svc.StartDownload(SearchRequest{
|
||||
LibraryID: 1,
|
||||
ReleaseGroupMBID: "rg-1",
|
||||
Artist: dl.Artist,
|
||||
Album: dl.Album,
|
||||
Expected: dl.Expected,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StartDownload: %v", err)
|
||||
}
|
||||
|
||||
if !result.AutoPicked {
|
||||
t.Fatal("expected a clear single-provider winner to auto-pick")
|
||||
}
|
||||
|
||||
waitForDownloadState(t, f.store, result.DownloadID, StateComplete)
|
||||
|
||||
waitFor(t, func() bool {
|
||||
req, found, err := f.store.FindRequest(ctx, "rg-1", 1)
|
||||
|
||||
return err == nil && found && req.State == RequestStateSatisfied
|
||||
}, "request was never satisfied after its manual download completed")
|
||||
}
|
||||
Reference in New Issue
Block a user