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
379 lines
9.3 KiB
Go
379 lines
9.3 KiB
Go
package download
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestNextRetryClimbsAndCaps(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC)
|
|
|
|
// The jitter makes exact equality wrong to assert, so each step is
|
|
// checked as a band around its nominal delay.
|
|
tests := []struct {
|
|
attempts int
|
|
nominal time.Duration
|
|
}{
|
|
{attempts: 1, nominal: requestRetryBase},
|
|
{attempts: 2, nominal: 2 * requestRetryBase},
|
|
{attempts: 3, nominal: 4 * requestRetryBase},
|
|
{attempts: 20, nominal: requestRetryMax},
|
|
{attempts: 500, nominal: requestRetryMax},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := nextRetry(now, tt.attempts).Sub(now)
|
|
|
|
lo := time.Duration(float64(tt.nominal) * (1 - requestRetryJitter))
|
|
hi := time.Duration(float64(tt.nominal) * (1 + requestRetryJitter))
|
|
|
|
if got < lo || got > hi {
|
|
t.Errorf(
|
|
"attempts=%d delay=%v, want within [%v, %v]",
|
|
tt.attempts, got, lo, hi,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A want that has been retried for years must not overflow into a
|
|
// negative delay, which would make it due forever.
|
|
func TestNextRetryNeverGoesBackwards(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Now()
|
|
|
|
for _, attempts := range []int{0, 1, 64, 1000, 1 << 20} {
|
|
if next := nextRetry(now, attempts); !next.After(now) {
|
|
t.Errorf("attempts=%d scheduled %v, not after now", attempts, next)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReleaseDateAfter(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
since := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
|
|
|
|
tests := []struct {
|
|
name string
|
|
date string
|
|
want bool
|
|
}{
|
|
{name: "later full date", date: "2026-06-01", want: true},
|
|
{name: "earlier full date", date: "2026-01-01", want: false},
|
|
{name: "same day is not after", date: "2026-03-15", want: false},
|
|
{name: "later year", date: "2027", want: true},
|
|
// A bare year is read as its 1 January, so the year of the
|
|
// subscription itself does not count as new.
|
|
{name: "same year, bare", date: "2026", want: false},
|
|
{name: "later month, bare", date: "2026-08", want: true},
|
|
{name: "earlier month, bare", date: "2026-02", want: false},
|
|
{name: "unknown date is old", date: "", want: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
if got := releaseDateAfter(tt.date, since); got != tt.want {
|
|
t.Errorf("%s: releaseDateAfter(%q) = %v, want %v",
|
|
tt.name, tt.date, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRequestsReleaseGroupFilters(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
subscribed := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
|
|
|
|
future := Request{Scope: ScopeFuture, CreatedAt: subscribed}
|
|
all := Request{Scope: ScopeAll, CreatedAt: subscribed}
|
|
allSecondary := Request{
|
|
Scope: ScopeAll, Secondary: true, CreatedAt: subscribed,
|
|
}
|
|
|
|
newAlbum := CatalogItem{MBID: "a", FirstReleaseDate: "2026-09-01"}
|
|
oldAlbum := CatalogItem{MBID: "b", FirstReleaseDate: "1997-04-22"}
|
|
ownedAlbum := CatalogItem{
|
|
MBID: "c", FirstReleaseDate: "2026-09-01", InLibrary: true,
|
|
}
|
|
liveAlbum := CatalogItem{
|
|
MBID: "d",
|
|
FirstReleaseDate: "2026-09-01",
|
|
SecondaryTypes: []string{"Live"},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
artist Request
|
|
rg CatalogItem
|
|
want bool
|
|
}{
|
|
{name: "future takes new", artist: future, rg: newAlbum, want: true},
|
|
{name: "future skips old", artist: future, rg: oldAlbum, want: false},
|
|
{name: "all takes old", artist: all, rg: oldAlbum, want: true},
|
|
{name: "owned is never wanted", artist: all, rg: ownedAlbum, want: false},
|
|
{name: "secondary off skips live", artist: all, rg: liveAlbum, want: false},
|
|
{
|
|
name: "secondary on takes live",
|
|
artist: allSecondary,
|
|
rg: liveAlbum,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "no mbid is unusable",
|
|
artist: all,
|
|
rg: CatalogItem{FirstReleaseDate: "2026-09-01"},
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
if got := requestsReleaseGroup(tt.artist, tt.rg); got != tt.want {
|
|
t.Errorf("%s: got %v, want %v", tt.name, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRequestToDownloadAnchors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
entity Entity
|
|
check func(Download) string
|
|
}{
|
|
{
|
|
entity: EntityReleaseGroup,
|
|
check: func(r Download) string {
|
|
return r.ReleaseGroupMBID
|
|
},
|
|
},
|
|
{
|
|
entity: EntityRelease,
|
|
check: func(r Download) string { return r.ReleaseMBID },
|
|
},
|
|
{
|
|
entity: EntityRecording,
|
|
check: func(r Download) string { return r.RecordingMBID },
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
req := Request{ID: 7, MBID: "mbid-x", Entity: tt.entity, LibraryID: 1}
|
|
|
|
dl := req.ToDownload("dl-1")
|
|
|
|
if got := tt.check(dl); got != "mbid-x" {
|
|
t.Errorf("%s: anchor = %q, want mbid-x", tt.entity, got)
|
|
}
|
|
|
|
if !dl.Anchored() {
|
|
t.Errorf("%s: download is not anchored", tt.entity)
|
|
}
|
|
|
|
if dl.RequestID != 7 {
|
|
t.Errorf("%s: RequestID = %d, want 7", tt.entity, dl.RequestID)
|
|
}
|
|
|
|
if dl.Source != requestSource {
|
|
t.Errorf("%s: Source = %q, want %q", tt.entity, dl.Source, requestSource)
|
|
}
|
|
}
|
|
}
|
|
|
|
// An anchored request with no tracklist has nothing to verify itself
|
|
// against, so it must not be auto-picked no matter how good the
|
|
// candidate looks.
|
|
func TestAutoPickableRequiresTracklist(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dl := Download{ReleaseGroupMBID: "rg-1", Artist: "A", Album: "B"}
|
|
|
|
ranked := []Candidate{{
|
|
Match: MatchScore{Overall: 0.99, Anchored: true},
|
|
Quality: QualityScore{Overall: 0.9},
|
|
Score: 0.95,
|
|
}}
|
|
|
|
if AutoPickable(dl, ranked, AutoDownloadPrefs{}) {
|
|
t.Error("auto-picked a download with no expected tracklist")
|
|
}
|
|
|
|
dl.Expected = []ExpectedTrack{{Position: 1, Title: "T"}}
|
|
|
|
if !AutoPickable(dl, ranked, AutoDownloadPrefs{}) {
|
|
t.Error("did not auto-pick a well-anchored, well-matched download")
|
|
}
|
|
}
|
|
|
|
// The wanted list's identity is the MBID, so the same one arriving
|
|
// twice — in a different case, with whitespace — is one row.
|
|
func TestAddRequestNormalizesAndDeduplicates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newManagerFixture(t)
|
|
ctx := context.Background()
|
|
|
|
first, err := f.store.AddRequest(ctx, Request{
|
|
MBID: " ABC-123 ",
|
|
Entity: EntityReleaseGroup,
|
|
LibraryID: 1,
|
|
Title: "OK Computer",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AddRequest: %v", err)
|
|
}
|
|
|
|
second, err := f.store.AddRequest(ctx, Request{
|
|
MBID: "abc-123",
|
|
Entity: EntityReleaseGroup,
|
|
LibraryID: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AddRequest again: %v", err)
|
|
}
|
|
|
|
if first != second {
|
|
t.Errorf("ids %d and %d, want the same row", first, second)
|
|
}
|
|
|
|
// Re-adding with no title must not wipe the one we have.
|
|
req, err := f.store.GetRequest(ctx, first)
|
|
if err != nil {
|
|
t.Fatalf("GetRequest: %v", err)
|
|
}
|
|
|
|
if req.Title != "OK Computer" {
|
|
t.Errorf("title = %q, want it preserved", req.Title)
|
|
}
|
|
|
|
if req.MBID != "abc-123" {
|
|
t.Errorf("mbid = %q, want normalized", req.MBID)
|
|
}
|
|
}
|
|
|
|
func TestRequestStoreLifecycle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newManagerFixture(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)
|
|
}
|
|
|
|
// A brand new want is due immediately.
|
|
due, err := f.store.ListDueRequests(ctx, 10)
|
|
if err != nil {
|
|
t.Fatalf("ListDueRequests: %v", err)
|
|
}
|
|
|
|
if len(due) != 1 {
|
|
t.Fatalf("got %d due requests, want 1", len(due))
|
|
}
|
|
|
|
// Recording an attempt pushes it out of the due set without
|
|
// changing its state: a want that was not found is still wanted.
|
|
if err := f.store.RecordAttempt(ctx, id, 0, "no source has it yet"); err != nil {
|
|
t.Fatalf("RecordAttempt: %v", err)
|
|
}
|
|
|
|
due, err = f.store.ListDueRequests(ctx, 10)
|
|
if err != nil {
|
|
t.Fatalf("ListDueRequests after attempt: %v", err)
|
|
}
|
|
|
|
if len(due) != 0 {
|
|
t.Errorf("got %d due requests after an attempt, want 0", len(due))
|
|
}
|
|
|
|
req, err := f.store.GetRequest(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("GetRequest: %v", err)
|
|
}
|
|
|
|
if req.State != RequestStateWanted {
|
|
t.Errorf("state = %q, want it still wanted", req.State)
|
|
}
|
|
|
|
if req.Attempts != 1 {
|
|
t.Errorf("attempts = %d, want 1", req.Attempts)
|
|
}
|
|
|
|
if req.LastError == "" {
|
|
t.Error("last error was not recorded")
|
|
}
|
|
|
|
if err := f.store.SatisfyRequest(ctx, id); err != nil {
|
|
t.Fatalf("SatisfyRequest: %v", err)
|
|
}
|
|
|
|
req, err = f.store.GetRequest(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("GetRequest after satisfy: %v", err)
|
|
}
|
|
|
|
if req.State != RequestStateSatisfied {
|
|
t.Errorf("state = %q, want satisfied", req.State)
|
|
}
|
|
}
|
|
|
|
// Removing an artist subscription takes the albums it derived with it,
|
|
// so a user who unsubscribes does not keep downloading that artist.
|
|
func TestDeleteArtistRequestCascadesToChildren(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := newManagerFixture(t)
|
|
ctx := context.Background()
|
|
|
|
artist, err := f.store.AddRequest(ctx, Request{
|
|
MBID: "artist-1",
|
|
Entity: EntityArtist,
|
|
LibraryID: 1,
|
|
Artist: "Radiohead",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AddRequest artist: %v", err)
|
|
}
|
|
|
|
if _, err := f.store.AddRequest(ctx, Request{
|
|
MBID: "rg-1",
|
|
Entity: EntityReleaseGroup,
|
|
LibraryID: 1,
|
|
ParentID: artist,
|
|
}); err != nil {
|
|
t.Fatalf("AddRequest child: %v", err)
|
|
}
|
|
|
|
children, err := f.store.ListChildRequests(ctx, artist)
|
|
if err != nil {
|
|
t.Fatalf("ListChildRequests: %v", err)
|
|
}
|
|
|
|
if len(children) != 1 {
|
|
t.Fatalf("got %d children, want 1", len(children))
|
|
}
|
|
|
|
if err := f.store.DeleteRequest(ctx, artist); err != nil {
|
|
t.Fatalf("DeleteRequest: %v", err)
|
|
}
|
|
|
|
all, err := f.store.ListRequests(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListRequests: %v", err)
|
|
}
|
|
|
|
if len(all) != 0 {
|
|
t.Errorf("got %d requests after deleting the artist, want 0", len(all))
|
|
}
|
|
}
|