Files
minstrel/internal/prowlarr/prowlarr_test.go
T
bvandeusenandClaude Opus 5.5 a2e1890d4c
release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m13s
release / go (push) Successful in 2m12s
release / integration (push) Successful in 4m6s
release / Build signed APK (releases and dev) (push) Successful in 8m41s
release / android (push) Successful in 9m47s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 59s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: indexers and download clients you add and configure (M502 #5518)
Admin → Integrations gains "Indexers and download clients": two lists of
connections the operator adds, Fabled Librarian's model. Nothing is seeded
or copied from Lidarr.

- Indexers: Prowlarr (its native /api/v1/search, which asks every indexer
  at once; Prowlarr has no aggregate Torznab feed) and any Torznab feed.
- Download clients: qBittorrent (WebUI v2, with re-login on an expired
  session) and FabledExchange, one entry with a switch per job:
  downloads (/api/downloads), search (/api/search) and release details
  (/api/torrents/{infohash}). All three start on; its key is optional.
- Each entry: enable, name, address, a write-only key or password, Test.
  A download client's Test lists its torrents and checks the downloads
  folder is visible and writable from Minstrel; an Exchange's Test
  reports each job switched on.
- Download clients carry a path mapping (Minstrel's /downloads vs the
  client's own name for it); search sources carry searches per hour.

Migration 0079 adds acquisition_services with its CHECKs. Ported from
Fabled Librarian: internal/indexer (Torznab, 429 Retry-After, magnet
redirects), internal/prowlarr, internal/exchange, internal/downloader,
and internal/acquire's one builder per kind, which Test already uses
so a passing Test vouches for the path a grab will take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:34:51 -04:00

79 lines
2.8 KiB
Go

package prowlarr
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
)
const sampleResults = `[
{"title": "Radiohead - In Rainbows (2007) [FLAC]", "infoHash": "AABBCCDDEEFF00112233445566778899AABBCCDD",
"downloadUrl": "http://prowlarr/1/download?link=x", "size": 400000000, "seeders": 30, "leechers": 4,
"indexer": "RED", "publishDate": "2026-10-01T12:00:00Z", "categories": [{"id": 3040}]},
{"title": "Radiohead - Amnesiac", "guid": "magnet:?xt=urn:btih:ffeeddccbbaa99887766554433221100ffeeddcc"},
{"title": ""}
]`
func TestSearch_AsksTheNativeAPIWithTheKeyInAHeader(t *testing.T) {
reqs := make(chan *http.Request, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqs <- r
_, _ = w.Write([]byte(sampleResults))
}))
defer srv.Close()
c := &Client{Config: Config{Code: "prowlarr", BaseURL: srv.URL + "/", APIKey: "secret"}}
results, err := c.Search(context.Background(), indexer.Query{Text: "radiohead", Categories: []int{3000}})
if err != nil {
t.Fatalf("search: %v", err)
}
got := <-reqs
if got.URL.Path != SearchPath || got.URL.Query().Get("type") != "search" || got.URL.Query().Get("categories") != "3000" {
t.Errorf("request = %s, want %s with type=search and the categories", got.URL, SearchPath)
}
if got.Header.Get("X-Api-Key") != "secret" || got.URL.Query().Get("apikey") != "" {
t.Error("the key belongs in X-Api-Key, never in the URL")
}
if len(results) != 2 {
t.Fatalf("results = %d, want 2", len(results))
}
if r := results[0]; r.InfoHash != "aabbccddeeff00112233445566778899aabbccdd" || r.Peers != 4 ||
r.Category != 3040 || r.Source != "prowlarr" || r.Published.IsZero() {
t.Errorf("first = %+v", r)
}
if r := results[1]; r.Magnet == "" || r.InfoHash != "ffeeddccbbaa99887766554433221100ffeeddcc" {
t.Errorf("second = %+v, want the GUID's magnet and its hash", r)
}
}
func TestSearch_Refusals(t *testing.T) {
for name, tc := range map[string]struct {
status int
want error
}{
"a refused key": {http.StatusUnauthorized, indexer.ErrAuth},
"an outage": {http.StatusBadGateway, indexer.ErrUnreachable},
} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tc.status)
}))
c := &Client{Config: Config{BaseURL: srv.URL, APIKey: "k"}}
_, err := c.Search(context.Background(), indexer.Query{Text: "x"})
srv.Close()
if !errors.Is(err, tc.want) {
t.Errorf("%s: err = %v, want %v", name, err, tc.want)
}
}
}
func TestSearch_NoKeyIsUnconfigured(t *testing.T) {
c := &Client{Config: Config{BaseURL: "http://prowlarr:9696"}}
if _, err := c.Search(context.Background(), indexer.Query{Text: "x"}); !errors.Is(err, indexer.ErrUnconfigured) {
t.Fatalf("err = %v, want ErrUnconfigured", err)
}
}