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
563 lines
12 KiB
Go
563 lines
12 KiB
Go
package download
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// lidarrStub is a fake Lidarr instance.
|
|
type lidarrStub struct {
|
|
server *httptest.Server
|
|
|
|
mu sync.Mutex
|
|
|
|
// searchAlbum is what /api/v1/search returns.
|
|
searchAlbum lidarrAlbum
|
|
|
|
// album is what /api/v1/album/{id} returns, in order; the last
|
|
// entry repeats.
|
|
albumStates []lidarrAlbum
|
|
pollCount int
|
|
|
|
// trackFiles is what /api/v1/trackfile returns.
|
|
trackFiles []lidarrTrackFile
|
|
|
|
// rootFolders is what /api/v1/rootfolder returns.
|
|
rootFolders []lidarrRootFolder
|
|
|
|
// commands records the commands that were issued.
|
|
commands []string
|
|
|
|
// monitorCalls records album-monitor toggles.
|
|
monitorCalls []map[string]any
|
|
|
|
// addedArtists records artist additions.
|
|
addedArtists []map[string]any
|
|
|
|
// artists is what a GET of /api/v1/artist returns, which is how the
|
|
// Lister role looks up and enumerates monitored artists.
|
|
artists []map[string]any
|
|
|
|
unauthorized bool
|
|
}
|
|
|
|
func newLidarrStub(t *testing.T) *lidarrStub {
|
|
t.Helper()
|
|
|
|
s := &lidarrStub{
|
|
rootFolders: []lidarrRootFolder{{ID: 1, Path: "/music"}},
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("/api/v1/system/status", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
writeJSON(t, w, map[string]any{"version": "2.0.0"})
|
|
})
|
|
|
|
mux.HandleFunc("/api/v1/rootfolder", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
folders := s.rootFolders
|
|
s.mu.Unlock()
|
|
|
|
writeJSON(t, w, folders)
|
|
})
|
|
|
|
mux.HandleFunc("/api/v1/qualityprofile", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
writeJSON(t, w, []map[string]any{{"id": 7}})
|
|
})
|
|
|
|
mux.HandleFunc("/api/v1/metadataprofile", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
writeJSON(t, w, []map[string]any{{"id": 3}})
|
|
})
|
|
|
|
mux.HandleFunc("/api/v1/search", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
album := s.searchAlbum
|
|
s.mu.Unlock()
|
|
|
|
writeJSON(t, w, []map[string]any{{"album": album}})
|
|
})
|
|
|
|
mux.HandleFunc("/api/v1/artist", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
if r.Method == http.MethodGet {
|
|
s.mu.Lock()
|
|
artists := s.artists
|
|
s.mu.Unlock()
|
|
|
|
if artists == nil {
|
|
artists = []map[string]any{}
|
|
}
|
|
|
|
writeJSON(t, w, artists)
|
|
|
|
return
|
|
}
|
|
|
|
var body map[string]any
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Errorf("decode artist body: %v", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.addedArtists = append(s.addedArtists, body)
|
|
s.mu.Unlock()
|
|
|
|
writeJSON(t, w, map[string]any{"id": 42})
|
|
})
|
|
|
|
mux.HandleFunc("/api/v1/album/monitor", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
var body map[string]any
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Errorf("decode monitor body: %v", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.monitorCalls = append(s.monitorCalls, body)
|
|
s.mu.Unlock()
|
|
|
|
w.WriteHeader(http.StatusAccepted)
|
|
})
|
|
|
|
mux.HandleFunc("/api/v1/album", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
album := s.searchAlbum
|
|
s.mu.Unlock()
|
|
|
|
album.ID = 99
|
|
|
|
writeJSON(t, w, []lidarrAlbum{album})
|
|
})
|
|
|
|
mux.HandleFunc("/api/v1/album/", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
|
|
idx := s.pollCount
|
|
if idx >= len(s.albumStates) {
|
|
idx = len(s.albumStates) - 1
|
|
} else {
|
|
s.pollCount++
|
|
}
|
|
|
|
var album lidarrAlbum
|
|
if idx >= 0 && len(s.albumStates) > 0 {
|
|
album = s.albumStates[idx]
|
|
}
|
|
|
|
s.mu.Unlock()
|
|
|
|
writeJSON(t, w, album)
|
|
})
|
|
|
|
mux.HandleFunc("/api/v1/trackfile", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
files := s.trackFiles
|
|
s.mu.Unlock()
|
|
|
|
writeJSON(t, w, files)
|
|
})
|
|
|
|
mux.HandleFunc("/api/v1/command", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
var body map[string]any
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Errorf("decode command body: %v", err)
|
|
}
|
|
|
|
name, _ := body["name"].(string)
|
|
|
|
s.mu.Lock()
|
|
s.commands = append(s.commands, name)
|
|
s.mu.Unlock()
|
|
|
|
writeJSON(t, w, map[string]any{"id": 1})
|
|
})
|
|
|
|
s.server = httptest.NewServer(mux)
|
|
t.Cleanup(s.server.Close)
|
|
|
|
return s
|
|
}
|
|
|
|
func (s *lidarrStub) reject(w http.ResponseWriter, r *http.Request) bool {
|
|
s.mu.Lock()
|
|
unauthorized := s.unauthorized
|
|
s.mu.Unlock()
|
|
|
|
if unauthorized || r.Header.Get("X-Api-Key") != "test-key" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func newStubLidarr(t *testing.T, stub *lidarrStub) *lidarr {
|
|
t.Helper()
|
|
|
|
p, err := newLidarr(
|
|
Config{
|
|
ID: 1,
|
|
Kind: KindLidarr,
|
|
Name: "lidarr",
|
|
Enabled: true,
|
|
Settings: map[string]string{
|
|
"url": stub.server.URL,
|
|
},
|
|
},
|
|
func(string) (string, error) { return "test-key", nil },
|
|
slogDiscard(),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("newLidarr: %v", err)
|
|
}
|
|
|
|
l, ok := p.(*lidarr)
|
|
if !ok {
|
|
t.Fatalf("provider is %T, want *lidarr", p)
|
|
}
|
|
|
|
return l
|
|
}
|
|
|
|
func TestLidarrCheck(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newLidarrStub(t)
|
|
l := newStubLidarr(t, stub)
|
|
|
|
if err := l.Check(context.Background()); err != nil {
|
|
t.Errorf("Check: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLidarrCheckRejectsBadKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newLidarrStub(t)
|
|
stub.unauthorized = true
|
|
|
|
l := newStubLidarr(t, stub)
|
|
|
|
if err := l.Check(context.Background()); !errors.Is(err, ErrLidarrAuth) {
|
|
t.Errorf("error = %v, want ErrLidarrAuth", err)
|
|
}
|
|
}
|
|
|
|
// Lidarr with nowhere to put music cannot fulfil anything, and that
|
|
// should be visible at configuration time.
|
|
func TestLidarrCheckRequiresRootFolder(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newLidarrStub(t)
|
|
stub.rootFolders = nil
|
|
|
|
l := newStubLidarr(t, stub)
|
|
|
|
if err := l.Check(context.Background()); !errors.Is(
|
|
err, ErrLidarrNoRootFolder,
|
|
) {
|
|
t.Errorf("error = %v, want ErrLidarrNoRootFolder", err)
|
|
}
|
|
}
|
|
|
|
// An album Lidarr already tracks only needs monitoring and a search.
|
|
func TestLidarrDelegateExistingAlbum(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newLidarrStub(t)
|
|
stub.searchAlbum = lidarrAlbum{
|
|
ID: 55,
|
|
Title: "OK Computer",
|
|
ForeignAlbum: "rg-mbid",
|
|
Monitored: false,
|
|
}
|
|
|
|
l := newStubLidarr(t, stub)
|
|
|
|
externalID, err := l.Delegate(context.Background(), Download{
|
|
ReleaseGroupMBID: "rg-mbid",
|
|
Artist: "Radiohead",
|
|
Album: "OK Computer",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Delegate: %v", err)
|
|
}
|
|
|
|
if externalID != "55" {
|
|
t.Errorf("external id = %q, want 55", externalID)
|
|
}
|
|
|
|
stub.mu.Lock()
|
|
commands := append([]string(nil), stub.commands...)
|
|
monitors := len(stub.monitorCalls)
|
|
added := len(stub.addedArtists)
|
|
stub.mu.Unlock()
|
|
|
|
if added != 0 {
|
|
t.Errorf("added %d artists, want 0 for an album Lidarr already has", added)
|
|
}
|
|
|
|
if monitors != 1 {
|
|
t.Errorf("monitor calls = %d, want 1", monitors)
|
|
}
|
|
|
|
if len(commands) != 1 || commands[0] != "AlbumSearch" {
|
|
t.Errorf("commands = %v, want [AlbumSearch]", commands)
|
|
}
|
|
}
|
|
|
|
// Adding an artist must not kick off their entire discography.
|
|
func TestLidarrDelegateNewArtistMonitorsNothingByDefault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newLidarrStub(t)
|
|
stub.searchAlbum = lidarrAlbum{
|
|
Title: "OK Computer",
|
|
ForeignAlbum: "rg-mbid",
|
|
}
|
|
stub.searchAlbum.Artist.ForeignArtistID = "artist-mbid"
|
|
stub.searchAlbum.Artist.ArtistName = "Radiohead"
|
|
|
|
l := newStubLidarr(t, stub)
|
|
|
|
if _, err := l.Delegate(context.Background(), Download{
|
|
ReleaseGroupMBID: "rg-mbid",
|
|
Artist: "Radiohead",
|
|
Album: "OK Computer",
|
|
}); err != nil {
|
|
t.Fatalf("Delegate: %v", err)
|
|
}
|
|
|
|
stub.mu.Lock()
|
|
added := append([]map[string]any(nil), stub.addedArtists...)
|
|
stub.mu.Unlock()
|
|
|
|
if len(added) != 1 {
|
|
t.Fatalf("added %d artists, want 1", len(added))
|
|
}
|
|
|
|
opts, ok := added[0]["addOptions"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("addOptions missing from %v", added[0])
|
|
}
|
|
|
|
if opts["monitor"] != "none" {
|
|
t.Errorf("monitor = %v, want none", opts["monitor"])
|
|
}
|
|
|
|
if opts["searchForMissingAlbums"] != false {
|
|
t.Errorf(
|
|
"searchForMissingAlbums = %v, want false",
|
|
opts["searchForMissingAlbums"],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestLidarrDelegateNoMatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newLidarrStub(t)
|
|
// searchAlbum stays zero-valued: no title means no match.
|
|
|
|
l := newStubLidarr(t, stub)
|
|
|
|
_, err := l.Delegate(context.Background(), Download{
|
|
Artist: "Nobody",
|
|
Album: "Nothing",
|
|
})
|
|
|
|
if !errors.Is(err, ErrLidarrNoMatch) {
|
|
t.Errorf("error = %v, want ErrLidarrNoMatch", err)
|
|
}
|
|
}
|
|
|
|
// Completion is judged by imported files, not by an empty queue: the
|
|
// queue drains when the download finishes, which is before the import.
|
|
func TestLidarrPollWaitsForImportedFiles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newLidarrStub(t)
|
|
stub.albumStates = []lidarrAlbum{
|
|
{ID: 55, Statistics: lidarrAlbumStats{TrackCount: 12}},
|
|
{ID: 55, Statistics: lidarrAlbumStats{TrackFileCount: 6, TrackCount: 12}},
|
|
{ID: 55, Statistics: lidarrAlbumStats{TrackFileCount: 12, TrackCount: 12}},
|
|
}
|
|
stub.trackFiles = []lidarrTrackFile{
|
|
{ID: 1, Path: "/music/Radiohead/OK Computer/01 Airbag.flac"},
|
|
{ID: 2, Path: "/music/Radiohead/OK Computer/02 Paranoid Android.flac"},
|
|
}
|
|
|
|
l := newStubLidarr(t, stub)
|
|
ctx := context.Background()
|
|
|
|
// Nothing imported yet.
|
|
first, err := l.Poll(ctx, "55")
|
|
if err != nil {
|
|
t.Fatalf("Poll: %v", err)
|
|
}
|
|
|
|
if first.State != StateGrabbing {
|
|
t.Errorf("state = %q, want grabbing", first.State)
|
|
}
|
|
|
|
// Half done.
|
|
second, err := l.Poll(ctx, "55")
|
|
if err != nil {
|
|
t.Fatalf("Poll: %v", err)
|
|
}
|
|
|
|
if second.State != StateGrabbing {
|
|
t.Errorf("state = %q, want grabbing", second.State)
|
|
}
|
|
|
|
if second.Progress <= first.Progress {
|
|
t.Errorf(
|
|
"progress did not advance: %f then %f",
|
|
first.Progress, second.Progress,
|
|
)
|
|
}
|
|
|
|
// Complete, with the paths Lidarr imported to.
|
|
third, err := l.Poll(ctx, "55")
|
|
if err != nil {
|
|
t.Fatalf("Poll: %v", err)
|
|
}
|
|
|
|
if third.State != StateComplete {
|
|
t.Fatalf("state = %q, want complete", third.State)
|
|
}
|
|
|
|
if len(third.ImportedPaths) != 2 {
|
|
t.Errorf("imported paths = %v, want 2", third.ImportedPaths)
|
|
}
|
|
|
|
for _, p := range third.ImportedPaths {
|
|
if !strings.HasPrefix(p, "/music/") {
|
|
t.Errorf("path %q is not in Lidarr's library", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLidarrPollRejectsBadExternalID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newLidarrStub(t)
|
|
l := newStubLidarr(t, stub)
|
|
|
|
if _, err := l.Poll(context.Background(), "not-a-number"); !errors.Is(
|
|
err, ErrLidarrNoMatch,
|
|
) {
|
|
t.Errorf("error = %v, want ErrLidarrNoMatch", err)
|
|
}
|
|
}
|
|
|
|
// Withdrawing stops monitoring; it must not delete the album, which may
|
|
// predate this request.
|
|
func TestLidarrWithdrawUnmonitorsOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newLidarrStub(t)
|
|
l := newStubLidarr(t, stub)
|
|
|
|
if err := l.Withdraw(context.Background(), "55"); err != nil {
|
|
t.Fatalf("Withdraw: %v", err)
|
|
}
|
|
|
|
stub.mu.Lock()
|
|
calls := append([]map[string]any(nil), stub.monitorCalls...)
|
|
stub.mu.Unlock()
|
|
|
|
if len(calls) != 1 {
|
|
t.Fatalf("monitor calls = %d, want 1", len(calls))
|
|
}
|
|
|
|
if calls[0]["monitored"] != false {
|
|
t.Errorf("monitored = %v, want false", calls[0]["monitored"])
|
|
}
|
|
}
|
|
|
|
func TestLidarrRequiresConfiguration(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("no url", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := newLidarr(
|
|
Config{},
|
|
func(string) (string, error) { return "k", nil },
|
|
slogDiscard(),
|
|
)
|
|
|
|
if !errors.Is(err, ErrNotConfigured) {
|
|
t.Errorf("error = %v, want ErrNotConfigured", err)
|
|
}
|
|
})
|
|
|
|
t.Run("no api key", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := newLidarr(
|
|
Config{Settings: map[string]string{"url": "http://localhost:8686"}},
|
|
func(string) (string, error) { return "", ErrSecretNotFound },
|
|
slogDiscard(),
|
|
)
|
|
|
|
if !errors.Is(err, ErrNotConfigured) {
|
|
t.Errorf("error = %v, want ErrNotConfigured", err)
|
|
}
|
|
})
|
|
}
|