Files
minstrel/internal/indexer/torznab_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

189 lines
6.6 KiB
Go

package indexer
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
const sampleFeed = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:torznab="http://torznab.com/schemas/2015/feed">
<channel>
<item>
<title>Radiohead - OK Computer (1997) [FLAC]</title>
<guid>https://tracker.example/details/1</guid>
<link>https://prowlarr.example/1/download?link=abc</link>
<size>512000000</size>
<pubDate>Tue, 07 Oct 2026 10:00:00 +0000</pubDate>
<torznab:attr name="seeders" value="12"/>
<torznab:attr name="peers" value="15"/>
<torznab:attr name="category" value="3040"/>
<torznab:attr name="indexer" value="RED"/>
<torznab:attr name="infohash" value="AABBCCDDEEFF00112233445566778899AABBCCDD"/>
</item>
<item>
<title>Radiohead - Kid A (2000) [MP3 320]</title>
<guid>0123456789abcdef0123456789abcdef01234567</guid>
<link>magnet:?xt=urn:btih:FFEEDDCCBBAA99887766554433221100FFEEDDCC&amp;dn=kid+a</link>
</item>
<item>
<title> </title>
</item>
</channel>
</rss>`
func TestTorznabSearch_ParsesTheFeedAndStampsTheSource(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(sampleFeed))
}))
defer srv.Close()
c := &Torznab{Config: TorznabConfig{Code: "torznab-2", FeedURL: srv.URL + "/12/api", APIKey: "k"}}
results, err := c.Search(context.Background(), Query{Text: "radiohead", Categories: MusicCategories})
if err != nil {
t.Fatalf("search: %v", err)
}
got := <-reqs
if got.URL.Path != "/12/api" || got.URL.Query().Get("t") != "search" || got.URL.Query().Get("apikey") != "k" {
t.Errorf("request = %s, want the feed URL with t=search and the key", got.URL)
}
if cats := got.URL.Query()["cat"]; len(cats) != 3 || cats[0] != "3000" {
t.Errorf("categories = %v, want the music categories", cats)
}
if len(results) != 2 {
t.Fatalf("results = %d, want 2 (the untitled item dropped)", len(results))
}
first := results[0]
if first.InfoHash != "aabbccddeeff00112233445566778899aabbccdd" || first.Seeders != 12 ||
first.Category != 3040 || first.Indexer != "RED" || first.DownloadURL == "" || first.Source != "torznab-2" {
t.Errorf("first = %+v", first)
}
if first.Published.IsZero() {
t.Error("pubDate was not parsed")
}
// The magnet's hash wins over a GUID shaped like another hash.
if results[1].Magnet == "" || results[1].InfoHash != "ffeeddccbbaa99887766554433221100ffeeddcc" {
t.Errorf("second = %+v, want the magnet's hash", results[1])
}
}
func TestTorznabSearch_KeepsAQueryAlreadyInTheFeedURL(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(`<rss><channel></channel></rss>`))
}))
defer srv.Close()
c := &Torznab{Config: TorznabConfig{FeedURL: srv.URL + "/api?passkey=p"}}
if _, err := c.Search(context.Background(), Query{Text: "x"}); err != nil {
t.Fatalf("search: %v", err)
}
got := <-reqs
if got.URL.Query().Get("passkey") != "p" || got.URL.Query().Get("q") != "x" {
t.Errorf("request = %s, want the pasted query kept and ours added", got.URL)
}
}
// Newznab reports a refused key as HTTP 200 with an <error> document. Read
// as a feed, that is an empty result set: search would quietly find nothing.
func TestTorznabSearch_ReadsAnErrorDocument(t *testing.T) {
for name, tc := range map[string]struct {
doc string
want error
}{
"a wrong key": {`<error code="100" description="Incorrect user credentials"/>`, ErrAuth},
"anything else": {`<error code="300" description="Indexer down"/>`, ErrUnreachable},
} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`<?xml version="1.0"?>` + tc.doc))
}))
c := &Torznab{Config: TorznabConfig{FeedURL: srv.URL + "/api"}}
_, err := c.Search(context.Background(), Query{Text: "x"})
srv.Close()
if !errors.Is(err, tc.want) {
t.Errorf("%s: err = %v, want %v", name, err, tc.want)
}
}
}
func TestTorznabSearch_A429IsRateLimitedWithItsRetryAfter(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Retry-After", "90")
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
c := &Torznab{Config: TorznabConfig{FeedURL: srv.URL + "/api"}}
_, err := c.Search(context.Background(), Query{Text: "x"})
rl, ok := AsRateLimited(err)
if !ok || rl.RetryAfter != 90*time.Second {
t.Fatalf("err = %v, want RateLimited for 90s", err)
}
if !errors.Is(err, ErrUnreachable) {
t.Error("a 429 should still read as unreachable to everything else")
}
}
func TestTorznabSearch_NoAddressIsUnconfigured(t *testing.T) {
_, err := (&Torznab{}).Search(context.Background(), Query{Text: "x"})
if !errors.Is(err, ErrUnconfigured) {
t.Fatalf("err = %v, want ErrUnconfigured", err)
}
}
func TestTorznabDownload_RefusesWhatIsNotATorrent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/login":
_, _ = w.Write([]byte(`<html>please log in</html>`))
case "/magnet":
_, _ = w.Write([]byte("magnet:?xt=urn:btih:abc"))
case "/redirect":
http.Redirect(w, r, "magnet:?xt=urn:btih:def", http.StatusFound)
default:
_, _ = w.Write([]byte("d8:announce0:e"))
}
}))
defer srv.Close()
c := &Torznab{Config: TorznabConfig{FeedURL: srv.URL + "/api"}}
ctx := context.Background()
if _, err := c.Download(ctx, Result{Title: "t", DownloadURL: srv.URL + "/login"}); !errors.Is(err, ErrUnreachable) {
t.Errorf("an HTML page: err = %v, want ErrUnreachable", err)
}
for _, path := range []string{"/magnet", "/redirect"} {
_, err := c.Download(ctx, Result{Title: "t", DownloadURL: srv.URL + path})
if _, ok := AsMagnet(err); !ok {
t.Errorf("%s: err = %v, want a MagnetLink", path, err)
}
}
body, err := c.Download(ctx, Result{Title: "t", DownloadURL: srv.URL + "/file"})
if err != nil || string(body) != "d8:announce0:e" {
t.Errorf("a torrent: body %q err %v", body, err)
}
}
func TestRetryAfter(t *testing.T) {
now := time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)
for v, want := range map[string]time.Duration{
"": 0,
"30": 30 * time.Second,
"soon": 0,
"Sat, 10 Oct 2026 00:02:00 GMT": 2 * time.Minute,
} {
h := http.Header{}
if v != "" {
h.Set("Retry-After", v)
}
if got := RetryAfter(h, now); got != want {
t.Errorf("RetryAfter(%q) = %v, want %v", v, got, want)
}
}
}