Files
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

157 lines
5.3 KiB
Go

package downloader
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
func TestPathMap(t *testing.T) {
m := PathMap{Local: "/downloads", Remote: "/data/torrents/"}
for in, want := range map[string]string{
"/downloads": "/data/torrents",
"/downloads/abc/x.mp3": "/data/torrents/abc/x.mp3",
"/downloads2/abc": "/downloads2/abc",
"/music/a": "/music/a",
"": "",
} {
if got := m.ToRemote(in); got != want {
t.Errorf("ToRemote(%q) = %q, want %q", in, got, want)
}
}
if got := m.ToLocal("/data/torrents/abc"); got != "/downloads/abc" {
t.Errorf("ToLocal = %q", got)
}
if (PathMap{Local: "/downloads", Remote: "/downloads/"}).Active() {
t.Error("the same folder under one name is not a mapping")
}
}
// fakeQbit is a qBittorrent WebUI that wants a login, and that expires the
// session once to make the client log in again.
func fakeQbit(t *testing.T, expireOnce bool) (*httptest.Server, *atomic.Int32) {
t.Helper()
var logins atomic.Int32
var expired atomic.Bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Referer") == "" {
w.WriteHeader(http.StatusForbidden)
return
}
switch r.URL.Path {
case "/api/v2/auth/login":
_ = r.ParseForm()
if r.Form.Get("password") != "pw" {
_, _ = w.Write([]byte("Fails."))
return
}
logins.Add(1)
http.SetCookie(w, &http.Cookie{Name: "SID", Value: "s", Path: "/"})
_, _ = w.Write([]byte("Ok."))
case "/api/v2/torrents/info":
if _, err := r.Cookie("SID"); err != nil {
w.WriteHeader(http.StatusForbidden)
return
}
if expireOnce && !expired.Swap(true) {
w.WriteHeader(http.StatusForbidden)
return
}
if r.URL.Query().Get("category") != "minstrel" {
t.Errorf("category = %q", r.URL.Query().Get("category"))
}
_, _ = w.Write([]byte(`[{"hash": "ABC", "name": "x", "save_path": "/data/torrents/abc", "progress": 1}]`))
case "/api/v2/torrents/add":
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), "/data/torrents/new") {
t.Errorf("add body has no translated save path: %s", body)
}
_, _ = w.Write([]byte("Ok."))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return srv, &logins
}
func TestQBittorrent_LogsInAndLogsInAgainWhenTheSessionExpires(t *testing.T) {
srv, logins := fakeQbit(t, true)
d := Mapped(&Client{Config: Config{BaseURL: srv.URL, Username: "u", Password: "pw", Category: "minstrel"}},
PathMap{Local: "/downloads", Remote: "/data/torrents"})
ts, err := d.Torrents(context.Background())
if err != nil {
t.Fatalf("torrents: %v", err)
}
if logins.Load() != 2 {
t.Errorf("logins = %d, want 2 (the second after the 403)", logins.Load())
}
if len(ts) != 1 || ts[0].Hash != "abc" || ts[0].SavePath != "/downloads/abc" || !ts[0].Done() {
t.Errorf("torrents = %+v, want one, hash lowercased, path in Minstrel's terms", ts)
}
if err := d.AddTorrent(context.Background(), Add{Magnet: "magnet:?xt=urn:btih:abc", SavePath: "/downloads/new"}); err != nil {
t.Errorf("add: %v", err)
}
}
func TestQBittorrent_AWrongPasswordIsAuth(t *testing.T) {
srv, _ := fakeQbit(t, false)
c := &Client{Config: Config{BaseURL: srv.URL, Password: "nope"}}
if _, err := c.Torrents(context.Background()); !errors.Is(err, ErrAuth) {
t.Fatalf("err = %v, want ErrAuth", err)
}
}
func TestExchange_MapsDownloadsAndRefusesWhileRestoring(t *testing.T) {
var restoring, sentKey atomic.Bool
restoring.Store(true)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" {
sentKey.Store(true)
}
pending := "0"
if restoring.Load() {
pending = "3"
}
_, _ = io.WriteString(w, `{"enabled": true, "running": false, "held_reason": "disk full", "restore_pending": `+pending+`, "downloads": [
{"infohash": "ABC", "name": "Album", "save_path": "/dl", "state": "seeding", "progress": 1, "length": 10, "bytes": 10, "category": "minstrel"},
{"infohash": "DEF", "name": "Other", "save_path": "/dl", "state": "fast", "category": "someone-else"},
{"infohash": "123", "name": "Broken", "save_path": "/dl", "failed": true, "fail_reason": "tracker gone", "category": "minstrel"}
]}`)
}))
defer srv.Close()
c := &Exchange{Config: Config{BaseURL: srv.URL, Category: "minstrel"}}
if _, err := c.Torrents(context.Background()); !errors.Is(err, ErrRestoring) {
t.Fatalf("while restoring: err = %v, want ErrRestoring", err)
}
if sentKey.Load() {
t.Error("an Authorization header was sent with no key set")
}
restoring.Store(false)
ts, err := c.Torrents(context.Background())
if err != nil {
t.Fatalf("torrents: %v", err)
}
if len(ts) != 2 {
t.Fatalf("torrents = %+v, want the two in Minstrel's category", ts)
}
if ts[0].Hash != "abc" || ts[0].State != "uploading" || ts[0].ContentPath != "/dl/Album" || !strings.Contains(ts[0].EngineDown, "disk full") {
t.Errorf("first = %+v", ts[0])
}
if !ts[1].Failed() || ts[1].FailReason != "tracker gone" {
t.Errorf("second = %+v, want failed with Exchange's reason", ts[1])
}
}
func TestFor_RefusesAnUnknownProtocol(t *testing.T) {
if _, err := For("transmission", Config{}); err == nil {
t.Fatal("want an error for a client Minstrel cannot drive")
}
}