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>
157 lines
5.3 KiB
Go
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")
|
|
}
|
|
}
|