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
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>
189 lines
6.6 KiB
Go
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&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)
|
|
}
|
|
}
|
|
}
|