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
241 lines
5.6 KiB
Go
241 lines
5.6 KiB
Go
package download
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
)
|
|
|
|
// An artist subscription becomes a monitored Lidarr artist, with the
|
|
// scope translated into Lidarr's own monitor option — so the policy
|
|
// keeps applying to albums released after the push, which is the whole
|
|
// reason to mirror a subscription rather than a list of albums.
|
|
func TestLidarrPushArtistRequestMapsScope(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
scope RequestScope
|
|
wantMonitor string
|
|
}{
|
|
{name: "future", scope: ScopeFuture, wantMonitor: "future"},
|
|
{name: "all", scope: ScopeAll, wantMonitor: "missing"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
stub := newLidarrStub(t)
|
|
l := newStubLidarr(t, stub)
|
|
|
|
id, err := l.PushRequest(context.Background(), Request{
|
|
MBID: "artist-mbid",
|
|
Entity: EntityArtist,
|
|
Artist: "Radiohead",
|
|
Scope: tt.scope,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("%s: PushRequest: %v", tt.name, err)
|
|
}
|
|
|
|
if id != "42" {
|
|
t.Errorf("%s: external id = %q, want 42", tt.name, id)
|
|
}
|
|
|
|
stub.mu.Lock()
|
|
added := append([]map[string]any(nil), stub.addedArtists...)
|
|
stub.mu.Unlock()
|
|
|
|
if len(added) != 1 {
|
|
t.Fatalf("%s: added %d artists, want 1", tt.name, len(added))
|
|
}
|
|
|
|
opts, ok := added[0]["addOptions"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("%s: no addOptions in the artist body", tt.name)
|
|
}
|
|
|
|
if opts["monitor"] != tt.wantMonitor {
|
|
t.Errorf(
|
|
"%s: monitor = %v, want %q",
|
|
tt.name, opts["monitor"], tt.wantMonitor,
|
|
)
|
|
}
|
|
|
|
// Adding an artist must never kick off a discography-wide
|
|
// search on a system the user shares with their own queue.
|
|
if opts["searchForMissingAlbums"] != false {
|
|
t.Errorf("%s: push triggered a search", tt.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pushing a want Lidarr already has returns the existing ID instead of
|
|
// adding a second copy — the reconciler pushes on every pass, so this
|
|
// is load-bearing rather than tidy.
|
|
func TestLidarrPushArtistRequestIsIdempotent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newLidarrStub(t)
|
|
stub.artists = []map[string]any{{
|
|
"id": 7,
|
|
"artistName": "Radiohead",
|
|
"foreignArtistId": "artist-mbid",
|
|
"monitored": true,
|
|
}}
|
|
|
|
l := newStubLidarr(t, stub)
|
|
|
|
id, err := l.PushRequest(context.Background(), Request{
|
|
MBID: "artist-mbid",
|
|
Entity: EntityArtist,
|
|
Artist: "Radiohead",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("PushRequest: %v", err)
|
|
}
|
|
|
|
if id != "7" {
|
|
t.Errorf("external id = %q, want the existing artist's 7", id)
|
|
}
|
|
|
|
stub.mu.Lock()
|
|
added := len(stub.addedArtists)
|
|
stub.mu.Unlock()
|
|
|
|
if added != 0 {
|
|
t.Errorf("added %d artists, want 0 — it already existed", added)
|
|
}
|
|
}
|
|
|
|
// Lidarr cannot express "I want one track", and monitoring the whole
|
|
// album to get it would download far more than was asked for.
|
|
func TestLidarrPushRecordingRequestIsSkipped(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newLidarrStub(t)
|
|
l := newStubLidarr(t, stub)
|
|
|
|
id, err := l.PushRequest(context.Background(), Request{
|
|
MBID: "recording-mbid",
|
|
Entity: EntityRecording,
|
|
Title: "Paranoid Android",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("PushRequest: %v", err)
|
|
}
|
|
|
|
if id != "" {
|
|
t.Errorf("external id = %q, want empty (not pushed)", id)
|
|
}
|
|
|
|
stub.mu.Lock()
|
|
added := len(stub.addedArtists)
|
|
monitors := len(stub.monitorCalls)
|
|
stub.mu.Unlock()
|
|
|
|
if added != 0 || monitors != 0 {
|
|
t.Errorf(
|
|
"track want touched Lidarr: %d artists, %d monitors",
|
|
added, monitors,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Importing adopts monitored artists conservatively: a subscription
|
|
// pulled in from elsewhere must not queue a back catalogue.
|
|
func TestLidarrListRequestsImportsMonitoredArtistsOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newLidarrStub(t)
|
|
stub.artists = []map[string]any{
|
|
{
|
|
"id": 1,
|
|
"artistName": "Radiohead",
|
|
"foreignArtistId": "artist-1",
|
|
"monitored": true,
|
|
},
|
|
{
|
|
"id": 2,
|
|
"artistName": "Unmonitored Band",
|
|
"foreignArtistId": "artist-2",
|
|
"monitored": false,
|
|
},
|
|
{
|
|
"id": 3,
|
|
"artistName": "No MBID",
|
|
"foreignArtistId": "",
|
|
"monitored": true,
|
|
},
|
|
}
|
|
|
|
l := newStubLidarr(t, stub)
|
|
|
|
requests, err := l.ListRequests(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ListRequests: %v", err)
|
|
}
|
|
|
|
if len(requests) != 1 {
|
|
t.Fatalf("imported %d requests, want 1", len(requests))
|
|
}
|
|
|
|
req := requests[0]
|
|
|
|
if req.MBID != "artist-1" {
|
|
t.Errorf("mbid = %q, want artist-1", req.MBID)
|
|
}
|
|
|
|
if req.Entity != EntityArtist {
|
|
t.Errorf("entity = %q, want artist", req.Entity)
|
|
}
|
|
|
|
if req.Scope != ScopeFuture {
|
|
t.Errorf("scope = %q, want the conservative future", req.Scope)
|
|
}
|
|
}
|
|
|
|
// Removing a want must not tear down a Lidarr setup that may predate
|
|
// this app: it unmonitors, it does not delete.
|
|
func TestLidarrRemoveRequestUnmonitorsOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newLidarrStub(t)
|
|
l := newStubLidarr(t, stub)
|
|
|
|
if err := l.RemoveRequest(context.Background(), "55"); err != nil {
|
|
t.Fatalf("RemoveRequest: %v", err)
|
|
}
|
|
|
|
stub.mu.Lock()
|
|
monitors := append([]map[string]any(nil), stub.monitorCalls...)
|
|
stub.mu.Unlock()
|
|
|
|
if len(monitors) != 1 {
|
|
t.Fatalf("got %d monitor calls, want 1", len(monitors))
|
|
}
|
|
|
|
if monitors[0]["monitored"] != false {
|
|
t.Errorf("monitored = %v, want false", monitors[0]["monitored"])
|
|
}
|
|
}
|
|
|
|
// The Lister role has to be declared, not just implemented, or the
|
|
// reconciler never finds it.
|
|
func TestLidarrDeclaresListerCapability(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newLidarrStub(t)
|
|
l := newStubLidarr(t, stub)
|
|
|
|
if _, ok := asLister(l); !ok {
|
|
t.Error("lidarr does not present as a Lister")
|
|
}
|
|
|
|
desc, ok := DescriptorFor(KindLidarr)
|
|
if !ok {
|
|
t.Fatal("no descriptor registered for lidarr")
|
|
}
|
|
|
|
if !desc.Caps.CanList {
|
|
t.Error("lidarr's descriptor does not declare CanList")
|
|
}
|
|
}
|