Files
yellowjacket/backend/download/provider_lidarr_test.go
yonluandClaude Sonnet 5 65333857e2
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s
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
2026-08-10 14:35:57 -04:00

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)
}
})
}