Ships the fresh-start schema cleanup: rebuilt explore catalog index pipeline (dump import, artifact fetch/build, incremental listen-count refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/ slskd/yt-dlp providers, staging, reconciliation, wanted list), and the supporting schema/query/store changes across backend and frontend. Also includes two smaller follow-ups: bump the central index's rebuild-after cadence from 90 to 180 days, and remove the Explore "library only" online/offline toggle entirely (frontend-only, no backend counterpart) rather than carry unused UI/state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
314 lines
6.8 KiB
Go
314 lines
6.8 KiB
Go
package download
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// prowlarrStub is a fake Prowlarr instance.
|
|
type prowlarrStub struct {
|
|
server *httptest.Server
|
|
|
|
mu sync.Mutex
|
|
|
|
results []prowlarrResult
|
|
indexers []map[string]any
|
|
|
|
// lastQuery records the search query string for assertions.
|
|
lastQuery string
|
|
|
|
unauthorized bool
|
|
}
|
|
|
|
func newProwlarrStub(t *testing.T) *prowlarrStub {
|
|
t.Helper()
|
|
|
|
s := &prowlarrStub{
|
|
indexers: []map[string]any{{"id": 1, "enable": true}},
|
|
}
|
|
|
|
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": "1.0.0"})
|
|
})
|
|
|
|
mux.HandleFunc("/api/v1/indexer", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
indexers := s.indexers
|
|
s.mu.Unlock()
|
|
|
|
writeJSON(t, w, indexers)
|
|
})
|
|
|
|
mux.HandleFunc("/api/v1/search", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.reject(w, r) {
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.lastQuery = r.URL.RawQuery
|
|
results := s.results
|
|
s.mu.Unlock()
|
|
|
|
writeJSON(t, w, results)
|
|
})
|
|
|
|
s.server = httptest.NewServer(mux)
|
|
t.Cleanup(s.server.Close)
|
|
|
|
return s
|
|
}
|
|
|
|
func (s *prowlarrStub) 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 newStubProwlarr(t *testing.T, stub *prowlarrStub, settings map[string]string) *prowlarr {
|
|
t.Helper()
|
|
|
|
if settings == nil {
|
|
settings = map[string]string{}
|
|
}
|
|
|
|
settings["url"] = stub.server.URL
|
|
|
|
p, err := newProwlarr(
|
|
Config{ID: 1, Kind: KindProwlarr, Name: "prowlarr", Settings: settings},
|
|
func(string) (string, error) { return "test-key", nil },
|
|
slogDiscard(),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("newProwlarr: %v", err)
|
|
}
|
|
|
|
pr, ok := p.(*prowlarr)
|
|
if !ok {
|
|
t.Fatalf("provider is %T, want *prowlarr", p)
|
|
}
|
|
|
|
return pr
|
|
}
|
|
|
|
func TestProwlarrCheck(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newProwlarrStub(t)
|
|
p := newStubProwlarr(t, stub, nil)
|
|
|
|
if err := p.Check(context.Background()); err != nil {
|
|
t.Errorf("Check: %v", err)
|
|
}
|
|
}
|
|
|
|
// Prowlarr with every indexer disabled will silently return nothing
|
|
// forever, which is worth surfacing at configuration time.
|
|
func TestProwlarrCheckRequiresEnabledIndexer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newProwlarrStub(t)
|
|
stub.indexers = []map[string]any{{"id": 1, "enable": false}}
|
|
|
|
p := newStubProwlarr(t, stub, nil)
|
|
|
|
if err := p.Check(context.Background()); !errors.Is(
|
|
err, ErrProwlarrNoIndexers,
|
|
) {
|
|
t.Errorf("error = %v, want ErrProwlarrNoIndexers", err)
|
|
}
|
|
}
|
|
|
|
// Prowlarr fills only the Searcher role, so its candidates must carry a
|
|
// protocol the pipeline can pair with a transport.
|
|
func TestProwlarrSearchMarksProtocols(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newProwlarrStub(t)
|
|
stub.results = []prowlarrResult{
|
|
{
|
|
GUID: "a",
|
|
Title: "Radiohead - OK Computer [FLAC]",
|
|
Indexer: "SomeTracker",
|
|
Protocol: "torrent",
|
|
Seeders: 50,
|
|
Size: 400_000_000,
|
|
MagnetURL: "magnet:?xt=urn:btih:abc123",
|
|
},
|
|
{
|
|
GUID: "b",
|
|
Title: "Radiohead - OK Computer [MP3]",
|
|
Indexer: "SomeUsenet",
|
|
Protocol: "usenet",
|
|
Size: 90_000_000,
|
|
DownloadURL: "https://example.com/x.nzb",
|
|
},
|
|
}
|
|
|
|
p := newStubProwlarr(t, stub, nil)
|
|
|
|
got, err := p.Search(context.Background(), Request{
|
|
Artist: "Radiohead",
|
|
Album: "OK Computer",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
|
|
if len(got) != 2 {
|
|
t.Fatalf("got %d candidates, want 2", len(got))
|
|
}
|
|
|
|
byProtocol := map[Protocol]Candidate{}
|
|
for _, c := range got {
|
|
byProtocol[c.Protocol] = c
|
|
}
|
|
|
|
torrent, ok := byProtocol[ProtocolTorrent]
|
|
if !ok {
|
|
t.Fatal("no torrent candidate")
|
|
}
|
|
|
|
if torrent.Payload["link"] != "magnet:?xt=urn:btih:abc123" {
|
|
t.Errorf("torrent link = %q, want the magnet", torrent.Payload["link"])
|
|
}
|
|
|
|
usenet, ok := byProtocol[ProtocolUsenet]
|
|
if !ok {
|
|
t.Fatal("no usenet candidate")
|
|
}
|
|
|
|
if usenet.Payload["link"] != "https://example.com/x.nzb" {
|
|
t.Errorf("usenet link = %q, want the NZB URL", usenet.Payload["link"])
|
|
}
|
|
|
|
// Indexer results are opaque before fetching; claiming a file list
|
|
// would be inventing information.
|
|
if len(torrent.Files) != 0 {
|
|
t.Errorf("torrent candidate has %d files, want none", len(torrent.Files))
|
|
}
|
|
}
|
|
|
|
// A torrent nobody is seeding will never finish, so offering it wastes
|
|
// the user's choice.
|
|
func TestProwlarrFiltersDeadTorrents(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newProwlarrStub(t)
|
|
stub.results = []prowlarrResult{
|
|
{
|
|
GUID: "dead", Title: "Dead", Protocol: "torrent",
|
|
Seeders: 0, MagnetURL: "magnet:?xt=urn:btih:dead",
|
|
},
|
|
{
|
|
GUID: "alive", Title: "Alive", Protocol: "torrent",
|
|
Seeders: 10, MagnetURL: "magnet:?xt=urn:btih:alive",
|
|
},
|
|
}
|
|
|
|
p := newStubProwlarr(t, stub, map[string]string{"minSeeders": "1"})
|
|
|
|
got, err := p.Search(context.Background(), Request{Query: "x"})
|
|
if err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
|
|
if len(got) != 1 {
|
|
t.Fatalf("got %d candidates, want 1", len(got))
|
|
}
|
|
|
|
if got[0].Title != "Alive" {
|
|
t.Errorf("kept %q, want the seeded torrent", got[0].Title)
|
|
}
|
|
}
|
|
|
|
// Searching without a category constraint returns films and software
|
|
// alongside music.
|
|
func TestProwlarrSearchesMusicCategory(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stub := newProwlarrStub(t)
|
|
p := newStubProwlarr(t, stub, nil)
|
|
|
|
if _, err := p.Search(
|
|
context.Background(), Request{Query: "radiohead"},
|
|
); err != nil {
|
|
t.Fatalf("Search: %v", err)
|
|
}
|
|
|
|
stub.mu.Lock()
|
|
query := stub.lastQuery
|
|
stub.mu.Unlock()
|
|
|
|
if !strings.Contains(query, "categories="+prowlarrMusicCategory) {
|
|
t.Errorf("query %q does not constrain to the music category", query)
|
|
}
|
|
}
|
|
|
|
func TestSwarmHealth(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// More seeders is never worse.
|
|
prev := -1.0
|
|
|
|
for _, seeders := range []int{0, 1, 3, 10, 50, 500} {
|
|
got := swarmHealth(ProtocolTorrent, seeders)
|
|
|
|
if got < prev {
|
|
t.Errorf("health fell at %d seeders: %f after %f", seeders, got, prev)
|
|
}
|
|
|
|
if got < 0 || got > 1 {
|
|
t.Errorf("health %f out of range at %d seeders", got, seeders)
|
|
}
|
|
|
|
prev = got
|
|
}
|
|
|
|
// Usenet has no swarm, so seeder count is meaningless there.
|
|
if a, b := swarmHealth(ProtocolUsenet, 0), swarmHealth(ProtocolUsenet, 99); a != b {
|
|
t.Errorf("usenet health varied with seeders: %f vs %f", a, b)
|
|
}
|
|
}
|
|
|
|
func TestProtocolFor(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := map[string]Protocol{
|
|
"torrent": ProtocolTorrent,
|
|
"Torrent": ProtocolTorrent,
|
|
"usenet": ProtocolUsenet,
|
|
"USENET": ProtocolUsenet,
|
|
"weird": ProtocolDirect,
|
|
"": ProtocolDirect,
|
|
}
|
|
|
|
for in, want := range tests {
|
|
if got := protocolFor(in); got != want {
|
|
t.Errorf("protocolFor(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|