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