feat: indexers and download clients you add and configure (M502 #5518)
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
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>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
package acquire
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/downloader"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/exchange"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/prowlarr"
|
||||
)
|
||||
|
||||
func validUpdate() Update {
|
||||
return Update{Label: "Prowlarr", BaseURL: "http://prowlarr:9696", TimeoutMs: 30000, LocalPath: "/downloads", SearchesPerHour: 120}
|
||||
}
|
||||
|
||||
func TestUpdateValidate(t *testing.T) {
|
||||
if err := validUpdate().validate(); err != nil {
|
||||
t.Fatalf("a valid form: %v", err)
|
||||
}
|
||||
for name, tc := range map[string]struct {
|
||||
edit func(*Update)
|
||||
field string
|
||||
}{
|
||||
"no name": {func(u *Update) { u.Label = "" }, "label"},
|
||||
"switched on with no address": {func(u *Update) { u.Enabled, u.BaseURL = true, "" }, "base_url"},
|
||||
"an address with no scheme": {func(u *Update) { u.BaseURL = "prowlarr:9696" }, "base_url"},
|
||||
"a timeout too short": {func(u *Update) { u.TimeoutMs = 10 }, "timeout_ms"},
|
||||
"a relative downloads folder": {func(u *Update) { u.LocalPath = "downloads" }, "local_path"},
|
||||
"a relative client folder": {func(u *Update) { u.RemotePath = "data" }, "remote_path"},
|
||||
"too many searches": {func(u *Update) { u.SearchesPerHour = 4000 }, "searches_per_hour"},
|
||||
} {
|
||||
u := validUpdate()
|
||||
tc.edit(&u)
|
||||
var invalid *InvalidError
|
||||
if err := u.validate(); !errors.As(err, &invalid) || invalid.Field != tc.field {
|
||||
t.Errorf("%s: err = %v, want an InvalidError on %s", name, err, tc.field)
|
||||
}
|
||||
}
|
||||
// Switched off, an empty address is a row still being set up.
|
||||
u := validUpdate()
|
||||
u.BaseURL = ""
|
||||
if err := u.validate(); err != nil {
|
||||
t.Errorf("switched off with no address: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceFor_BuildsTheClientTheProtocolNames(t *testing.T) {
|
||||
for protocol, check := range map[string]func(Source) bool{
|
||||
ProtocolProwlarr: func(s Source) bool { _, ok := s.Search.(*prowlarr.Client); return ok && s.Fetch != nil },
|
||||
ProtocolTorznab: func(s Source) bool { _, ok := s.Search.(*indexer.Torznab); return ok && s.Fetch != nil },
|
||||
// Exchange's results are all magnets: nothing to fetch through it.
|
||||
ProtocolExchange: func(s Source) bool { _, ok := s.Search.(*exchange.Client); return ok && s.Fetch == nil },
|
||||
} {
|
||||
src := SourceFor(dbq.AcquisitionService{Code: protocol, Protocol: protocol, SearchesPerHour: 7})
|
||||
if !check(src) || src.Code != protocol || src.PerHour != 7 {
|
||||
t.Errorf("%s: source %+v", protocol, src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobs_AnExchangeDoesWhatItsSwitchesAllow(t *testing.T) {
|
||||
ex := dbq.AcquisitionService{Kind: KindDownload, Protocol: ProtocolExchange, ServesSearch: true}
|
||||
if !Searches(ex) || Fetches(ex) {
|
||||
t.Error("an exchange with only search on should search and not fetch")
|
||||
}
|
||||
// A qBittorrent's switches mean nothing: it fetches, whatever they say.
|
||||
qb := dbq.AcquisitionService{Kind: KindDownload, Protocol: ProtocolQBittorrent, ServesSearch: true}
|
||||
if Searches(qb) || !Fetches(qb) {
|
||||
t.Error("a qBittorrent fetches and never searches")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTest_NoAddressIsNothingToReach(t *testing.T) {
|
||||
r := Test(context.Background(), dbq.AcquisitionService{Protocol: ProtocolProwlarr, Kind: KindSearch})
|
||||
if r.OK || !strings.Contains(r.Detail, "No address") {
|
||||
t.Fatalf("result = %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTest_SearchReportsResultsAndARefusedKey(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Api-Key") != "good" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`[{"title": "Radiohead - OK Computer"}]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
row := dbq.AcquisitionService{Kind: KindSearch, Protocol: ProtocolProwlarr, BaseUrl: srv.URL, ApiKey: "good", TimeoutMs: 5000}
|
||||
if r := Test(context.Background(), row); !r.OK || !strings.Contains(r.Detail, "1 results") {
|
||||
t.Errorf("a working key: %+v", r)
|
||||
}
|
||||
row.ApiKey = "bad"
|
||||
if r := Test(context.Background(), row); r.OK || !strings.Contains(r.Detail, "refused") {
|
||||
t.Errorf("a refused key: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeExchange answers the three native APIs.
|
||||
func fakeExchange(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/api/downloads":
|
||||
_, _ = w.Write([]byte(`{"enabled": true, "running": true, "inbound_conns": 3, "downloads": []}`))
|
||||
case r.URL.Path == "/api/search":
|
||||
_, _ = w.Write([]byte(`{"total": 0, "results": []}`))
|
||||
case strings.HasPrefix(r.URL.Path, "/api/torrents/"):
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error": "unknown torrent"}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestTest_AnExchangeReportsEachJobSwitchedOn(t *testing.T) {
|
||||
srv := fakeExchange(t)
|
||||
row := dbq.AcquisitionService{
|
||||
Kind: KindDownload, Protocol: ProtocolExchange, BaseUrl: srv.URL, TimeoutMs: 5000,
|
||||
ServesDownloads: true, ServesSearch: true, ServesEnrichment: true, LocalPath: t.TempDir(),
|
||||
}
|
||||
r := Test(context.Background(), row)
|
||||
if !r.OK {
|
||||
t.Fatalf("all three jobs: %+v", r)
|
||||
}
|
||||
for _, job := range []string{"Downloads:", "Search:", "Release details:"} {
|
||||
if !strings.Contains(r.Detail, job) {
|
||||
t.Errorf("detail %q does not report %s", r.Detail, job)
|
||||
}
|
||||
}
|
||||
row.ServesDownloads, row.ServesEnrichment = false, false
|
||||
if r := Test(context.Background(), row); strings.Contains(r.Detail, "Downloads:") || !strings.Contains(r.Detail, "Search:") {
|
||||
t.Errorf("only search on: %+v", r)
|
||||
}
|
||||
row.ServesSearch = false
|
||||
if r := Test(context.Background(), row); r.OK || !strings.Contains(r.Detail, "switched off") {
|
||||
t.Errorf("every job off: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTest_ADownloadClientNeedsAFolderMinstrelCanSee(t *testing.T) {
|
||||
srv := fakeExchange(t)
|
||||
row := dbq.AcquisitionService{
|
||||
Kind: KindDownload, Protocol: ProtocolExchange, BaseUrl: srv.URL, TimeoutMs: 5000,
|
||||
ServesDownloads: true, Category: DefaultCategory,
|
||||
LocalPath: filepath.Join(t.TempDir(), "not-mounted"),
|
||||
}
|
||||
r := Test(context.Background(), row)
|
||||
if r.OK || !strings.Contains(r.Detail, "cannot see its downloads folder") {
|
||||
t.Fatalf("a missing folder: %+v", r)
|
||||
}
|
||||
row.LocalPath = t.TempDir()
|
||||
if r := Test(context.Background(), row); !r.OK || !strings.Contains(r.Detail, `"minstrel" category`) {
|
||||
t.Errorf("a visible, writable folder: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadClient_WrapsThePathMapping(t *testing.T) {
|
||||
d, err := DownloadClient(dbq.AcquisitionService{Protocol: ProtocolQBittorrent, LocalPath: "/downloads", RemotePath: "/data"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, plain := d.(*downloader.Client); plain {
|
||||
t.Error("a row naming two folders should come back wrapped in its mapping")
|
||||
}
|
||||
if _, err := DownloadClient(dbq.AcquisitionService{Protocol: ProtocolProwlarr}); err == nil {
|
||||
t.Error("a search protocol is not a download client")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Package acquire turns the operator's configured indexers and download
|
||||
// clients into working clients (M502 #5518). Ported from Fabled Librarian's
|
||||
// internal/acquire.
|
||||
//
|
||||
// ONE BUILDER PER KIND. SourceFor makes a search source from a row and
|
||||
// DownloadClient makes a download client, path mapping included. The grab,
|
||||
// the importer, the stall watchdog and the settings page's Test all build
|
||||
// through them, so a Test that passes vouches for the path real traffic takes.
|
||||
// Clients are rebuilt from the row on every use, so a changed address or a new
|
||||
// key takes effect on the next call.
|
||||
package acquire
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/downloader"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/exchange"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/prowlarr"
|
||||
)
|
||||
|
||||
// Kinds: which list a row appears in.
|
||||
const (
|
||||
KindSearch = "search"
|
||||
KindDownload = "download"
|
||||
)
|
||||
|
||||
// Jobs a row can do. An exchange row does each its switch allows; every
|
||||
// other row does the one its kind names.
|
||||
const (
|
||||
JobSearch = "search"
|
||||
JobDownload = "download"
|
||||
JobEnrichment = "enrichment"
|
||||
)
|
||||
|
||||
// Protocols: which client a row builds.
|
||||
const (
|
||||
ProtocolProwlarr = "prowlarr"
|
||||
ProtocolTorznab = "torznab"
|
||||
ProtocolQBittorrent = "qbittorrent"
|
||||
ProtocolExchange = "exchange"
|
||||
)
|
||||
|
||||
// DefaultCategory files everything Minstrel adds to a download client.
|
||||
const DefaultCategory = "minstrel"
|
||||
|
||||
// addable is what a new row of a protocol starts as.
|
||||
type addable struct {
|
||||
kind string
|
||||
label string
|
||||
category string
|
||||
timeoutMs int32
|
||||
perHour int32
|
||||
}
|
||||
|
||||
// catalogue is every protocol the operator can add. Exchange starts unpaced
|
||||
// (it searches its own database); the others at 120 searches an hour, which
|
||||
// keeps a Prowlarr's indexers under the rate most trackers allow.
|
||||
var catalogue = map[string]addable{
|
||||
ProtocolProwlarr: {kind: KindSearch, label: "Prowlarr", timeoutMs: 60000, perHour: 120},
|
||||
ProtocolTorznab: {kind: KindSearch, label: "Torznab feed", timeoutMs: 30000, perHour: 120},
|
||||
ProtocolQBittorrent: {kind: KindDownload, label: "qBittorrent", category: DefaultCategory, timeoutMs: 15000, perHour: 0},
|
||||
ProtocolExchange: {kind: KindDownload, label: "FabledExchange", category: DefaultCategory, timeoutMs: 30000, perHour: 0},
|
||||
}
|
||||
|
||||
// Addable says whether protocol is one the operator can add.
|
||||
func Addable(protocol string) bool {
|
||||
_, ok := catalogue[protocol]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Searches says whether a row is a search source.
|
||||
func Searches(row dbq.AcquisitionService) bool {
|
||||
if row.Protocol == ProtocolExchange {
|
||||
return row.ServesSearch
|
||||
}
|
||||
return row.Kind == KindSearch
|
||||
}
|
||||
|
||||
// Fetches says whether a row is a download client.
|
||||
func Fetches(row dbq.AcquisitionService) bool {
|
||||
if row.Protocol == ProtocolExchange {
|
||||
return row.ServesDownloads
|
||||
}
|
||||
return row.Kind == KindDownload
|
||||
}
|
||||
|
||||
// Searcher is a search source.
|
||||
type Searcher interface {
|
||||
Search(ctx context.Context, q indexer.Query) ([]indexer.Result, error)
|
||||
}
|
||||
|
||||
// Fetcher fetches a result's .torrent through the source that offered it.
|
||||
type Fetcher interface {
|
||||
Download(ctx context.Context, r indexer.Result) ([]byte, error)
|
||||
}
|
||||
|
||||
// Source is one configured search source.
|
||||
type Source struct {
|
||||
Code string
|
||||
Label string
|
||||
// PerHour is the most searches the schedule sends it an hour; 0 = unpaced.
|
||||
PerHour int
|
||||
Search Searcher
|
||||
// Fetch is nil for a source whose results are all magnets.
|
||||
Fetch Fetcher
|
||||
}
|
||||
|
||||
// SourceFor builds the search source a row describes.
|
||||
func SourceFor(row dbq.AcquisitionService) Source {
|
||||
src := Source{Code: row.Code, Label: row.Label, PerHour: int(row.SearchesPerHour)}
|
||||
timeout := msDuration(row.TimeoutMs)
|
||||
switch row.Protocol {
|
||||
case ProtocolProwlarr:
|
||||
c := &prowlarr.Client{Config: prowlarr.Config{
|
||||
Code: row.Code, BaseURL: row.BaseUrl, APIKey: row.ApiKey, Timeout: timeout,
|
||||
}}
|
||||
src.Search, src.Fetch = c, c
|
||||
case ProtocolExchange:
|
||||
src.Search = ExchangeFor(row)
|
||||
default:
|
||||
c := &indexer.Torznab{Config: indexer.TorznabConfig{
|
||||
Code: row.Code, FeedURL: row.BaseUrl, APIKey: row.ApiKey, Timeout: timeout,
|
||||
}}
|
||||
src.Search, src.Fetch = c, c
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// ExchangeFor builds the search and enrichment client for an exchange row.
|
||||
func ExchangeFor(row dbq.AcquisitionService) *exchange.Client {
|
||||
return &exchange.Client{
|
||||
Code: row.Code,
|
||||
Config: exchange.Config{
|
||||
BaseURL: row.BaseUrl, APIKey: row.ApiKey, Timeout: msDuration(row.TimeoutMs),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadClient builds the download client a row describes, wrapped in its
|
||||
// path mapping.
|
||||
func DownloadClient(row dbq.AcquisitionService) (downloader.Driver, error) {
|
||||
d, err := downloader.For(row.Protocol, downloader.Config{
|
||||
BaseURL: row.BaseUrl, Username: row.Username, Password: row.Password,
|
||||
APIKey: row.ApiKey, Category: row.Category, Timeout: msDuration(row.TimeoutMs),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return downloader.Mapped(d, downloader.PathMap{Local: row.LocalPath, Remote: row.RemotePath}), nil
|
||||
}
|
||||
|
||||
// Sources builds every enabled search source.
|
||||
func Sources(ctx context.Context, pool *pgxpool.Pool) ([]Source, error) {
|
||||
rows, err := dbq.New(pool).ListUsableAcquisitionServices(ctx, JobSearch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Source, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = SourceFor(r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func msDuration(ms int32) time.Duration { return time.Duration(ms) * time.Millisecond }
|
||||
@@ -0,0 +1,191 @@
|
||||
package acquire
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// ErrNotFound is a code with no row.
|
||||
var ErrNotFound = errors.New("no such service")
|
||||
|
||||
// ErrTooMany is a twenty-first row of one protocol.
|
||||
var ErrTooMany = errors.New("there are already twenty of these")
|
||||
|
||||
// InvalidError is a setting the operator has to change, in words for the
|
||||
// settings card.
|
||||
type InvalidError struct {
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *InvalidError) Error() string { return e.Message }
|
||||
|
||||
func invalid(field, msg string) error { return &InvalidError{Field: field, Message: msg} }
|
||||
|
||||
// maxPerProtocol bounds the codes tried for a new row. Nobody runs twenty
|
||||
// Prowlarrs; the bound keeps a loop over a full table finite.
|
||||
const maxPerProtocol = 20
|
||||
|
||||
// Store reads and writes acquisition_services.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewStore returns a Store over pool.
|
||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||
|
||||
// List returns every row without its secrets.
|
||||
func (s *Store) List(ctx context.Context) ([]dbq.ListAcquisitionServicesRow, error) {
|
||||
return dbq.New(s.pool).ListAcquisitionServices(ctx)
|
||||
}
|
||||
|
||||
// Get returns one row, secrets included: for building a client only.
|
||||
func (s *Store) Get(ctx context.Context, code string) (dbq.AcquisitionService, error) {
|
||||
row, err := dbq.New(s.pool).GetAcquisitionService(ctx, code)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return row, ErrNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
// Create adds a row of protocol, switched off and with no address. Its code
|
||||
// is the protocol for the first, then protocol-2, protocol-3.
|
||||
func (s *Store) Create(ctx context.Context, protocol string) (dbq.AcquisitionService, error) {
|
||||
a, ok := catalogue[protocol]
|
||||
if !ok {
|
||||
return dbq.AcquisitionService{}, invalid("protocol", "One of prowlarr, torznab, qbittorrent, exchange.")
|
||||
}
|
||||
q := dbq.New(s.pool)
|
||||
for n := 1; n <= maxPerProtocol; n++ {
|
||||
code, label := protocol, a.label
|
||||
if n > 1 {
|
||||
code, label = fmt.Sprintf("%s-%d", protocol, n), fmt.Sprintf("%s %d", a.label, n)
|
||||
}
|
||||
row, err := q.CreateAcquisitionService(ctx, dbq.CreateAcquisitionServiceParams{
|
||||
Code: code, Kind: a.kind, Protocol: protocol, Label: label,
|
||||
Category: a.category, TimeoutMs: a.timeoutMs, SearchesPerHour: a.perHour,
|
||||
})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
return dbq.AcquisitionService{}, ErrTooMany
|
||||
}
|
||||
|
||||
// Update is everything on the settings form except the credentials.
|
||||
type Update struct {
|
||||
Label string
|
||||
Enabled bool
|
||||
BaseURL string
|
||||
Username string
|
||||
Category string
|
||||
TimeoutMs int32
|
||||
ServesSearch bool
|
||||
ServesDownloads bool
|
||||
ServesEnrichment bool
|
||||
LocalPath string
|
||||
RemotePath string
|
||||
SearchesPerHour int32
|
||||
}
|
||||
|
||||
// Save validates u and writes it to code's row.
|
||||
func (s *Store) Save(ctx context.Context, code string, u Update) (dbq.AcquisitionService, error) {
|
||||
u.Label = strings.TrimSpace(u.Label)
|
||||
u.BaseURL = strings.TrimSpace(u.BaseURL)
|
||||
u.Username = strings.TrimSpace(u.Username)
|
||||
u.Category = strings.TrimSpace(u.Category)
|
||||
u.LocalPath = strings.TrimSpace(u.LocalPath)
|
||||
u.RemotePath = strings.TrimSpace(u.RemotePath)
|
||||
if err := u.validate(); err != nil {
|
||||
return dbq.AcquisitionService{}, err
|
||||
}
|
||||
row, err := dbq.New(s.pool).UpdateAcquisitionService(ctx, dbq.UpdateAcquisitionServiceParams{
|
||||
Code: code, Label: u.Label, Enabled: u.Enabled, BaseUrl: u.BaseURL,
|
||||
Username: u.Username, Category: u.Category, TimeoutMs: u.TimeoutMs,
|
||||
ServesSearch: u.ServesSearch, ServesDownloads: u.ServesDownloads,
|
||||
ServesEnrichment: u.ServesEnrichment, LocalPath: u.LocalPath,
|
||||
RemotePath: u.RemotePath, SearchesPerHour: u.SearchesPerHour,
|
||||
})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return row, ErrNotFound
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
// validate mirrors the table's CHECKs, so a bad value is named in the card's
|
||||
// own terms before Postgres refuses it.
|
||||
func (u Update) validate() error {
|
||||
if u.Label == "" {
|
||||
return invalid("label", "Name: give it a name.")
|
||||
}
|
||||
if u.Enabled && u.BaseURL == "" {
|
||||
return invalid("base_url", "Address: give the address before switching this on.")
|
||||
}
|
||||
if u.BaseURL != "" {
|
||||
parsed, err := url.Parse(u.BaseURL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return invalid("base_url", "Address: start with http:// or https://.")
|
||||
}
|
||||
}
|
||||
if u.TimeoutMs < 1000 || u.TimeoutMs > 300000 {
|
||||
return invalid("timeout_ms", "Give up after: between 1000 and 300000 milliseconds.")
|
||||
}
|
||||
if !strings.HasPrefix(u.LocalPath, "/") {
|
||||
return invalid("local_path", "Downloads folder as Minstrel sees it: a full path, starting with /.")
|
||||
}
|
||||
if u.RemotePath != "" && !strings.HasPrefix(u.RemotePath, "/") {
|
||||
return invalid("remote_path", "Downloads folder as the client sees it: a full path starting with /, or empty when it is the same path.")
|
||||
}
|
||||
if u.SearchesPerHour < 0 || u.SearchesPerHour > 3600 {
|
||||
return invalid("searches_per_hour", "Searches per hour: between 0 and 3600. 0 means no limit.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes code's row.
|
||||
func (s *Store) Delete(ctx context.Context, code string) error {
|
||||
n, err := dbq.New(s.pool).DeleteAcquisitionService(ctx, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAPIKey stores a key, trimmed; empty clears it.
|
||||
func (s *Store) SetAPIKey(ctx context.Context, code, key string) error {
|
||||
n, err := dbq.New(s.pool).SetAcquisitionServiceAPIKey(ctx, dbq.SetAcquisitionServiceAPIKeyParams{
|
||||
Code: code, ApiKey: strings.TrimSpace(key),
|
||||
})
|
||||
return affected(n, err)
|
||||
}
|
||||
|
||||
// SetPassword stores a password as typed (spaces are part of a password);
|
||||
// empty clears it.
|
||||
func (s *Store) SetPassword(ctx context.Context, code, password string) error {
|
||||
n, err := dbq.New(s.pool).SetAcquisitionServicePassword(ctx, dbq.SetAcquisitionServicePasswordParams{
|
||||
Code: code, Password: password,
|
||||
})
|
||||
return affected(n, err)
|
||||
}
|
||||
|
||||
func affected(n int64, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package acquire
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||
)
|
||||
|
||||
func newPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in -short mode")
|
||||
}
|
||||
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
dbtest.ResetDB(t, pool)
|
||||
return pool
|
||||
}
|
||||
|
||||
func TestStore_AddsConfiguresAndRemoves_Integration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := NewStore(newPool(t))
|
||||
|
||||
first, err := s.Create(ctx, ProtocolExchange)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
second, err := s.Create(ctx, ProtocolExchange)
|
||||
if err != nil {
|
||||
t.Fatalf("create a second: %v", err)
|
||||
}
|
||||
if first.Code != "exchange" || second.Code != "exchange-2" || second.Label != "FabledExchange 2" {
|
||||
t.Errorf("codes = %q, %q (%q); want exchange, exchange-2", first.Code, second.Code, second.Label)
|
||||
}
|
||||
// A new exchange is off, does every job, files under minstrel and is
|
||||
// unpaced.
|
||||
if first.Enabled || first.Kind != KindDownload || !first.ServesSearch || !first.ServesDownloads ||
|
||||
!first.ServesEnrichment || first.Category != DefaultCategory || first.SearchesPerHour != 0 || first.LocalPath != "/downloads" {
|
||||
t.Errorf("a new exchange = %+v", first)
|
||||
}
|
||||
if _, err := s.Create(ctx, "transmission"); !errors.As(err, new(*InvalidError)) {
|
||||
t.Errorf("an unknown protocol: err = %v", err)
|
||||
}
|
||||
|
||||
u := Update{
|
||||
Label: "Exchange", Enabled: true, BaseURL: "http://exchange:8080", Category: "minstrel",
|
||||
TimeoutMs: 20000, ServesSearch: true, LocalPath: "/downloads", RemotePath: "/data/torrents",
|
||||
}
|
||||
saved, err := s.Save(ctx, first.Code, u)
|
||||
if err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
if !saved.Enabled || saved.ServesDownloads || saved.RemotePath != "/data/torrents" {
|
||||
t.Errorf("saved = %+v", saved)
|
||||
}
|
||||
if _, err := s.Save(ctx, "nope", u); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("saving a missing row: err = %v", err)
|
||||
}
|
||||
|
||||
// Secrets are write-only: the list says whether one is set, never what.
|
||||
if err := s.SetAPIKey(ctx, first.Code, " key "); err != nil {
|
||||
t.Fatalf("set key: %v", err)
|
||||
}
|
||||
full, err := s.Get(ctx, first.Code)
|
||||
if err != nil || full.ApiKey != "key" {
|
||||
t.Errorf("stored key = %q (err %v), want it trimmed", full.ApiKey, err)
|
||||
}
|
||||
rows, err := s.List(ctx)
|
||||
if err != nil || len(rows) != 2 {
|
||||
t.Fatalf("list: %d rows, err %v", len(rows), err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Code == first.Code && !r.HasApiKey {
|
||||
t.Error("the list should say a key is set")
|
||||
}
|
||||
}
|
||||
|
||||
// Only the exchange with search switched on is a search source.
|
||||
srcs, err := Sources(ctx, s.pool)
|
||||
if err != nil || len(srcs) != 1 || srcs[0].Code != first.Code {
|
||||
t.Errorf("sources = %+v, err %v; want only the enabled exchange", srcs, err)
|
||||
}
|
||||
|
||||
if err := s.Delete(ctx, second.Code); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if err := s.Delete(ctx, second.Code); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("deleting twice: err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package acquire
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/exchange"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
||||
)
|
||||
|
||||
// TestResult is what the settings card says after Test.
|
||||
type TestResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// testQuery is a search every music indexer should answer.
|
||||
const testQuery = "Radiohead"
|
||||
|
||||
// Test reaches the service a row describes, through the same builders real
|
||||
// traffic uses. It runs whether or not the row is switched on: paste the
|
||||
// address, test it, then switch it on.
|
||||
func Test(ctx context.Context, row dbq.AcquisitionService) TestResult {
|
||||
if strings.TrimSpace(row.BaseUrl) == "" {
|
||||
return TestResult{Detail: "No address is set, so there is nothing to reach yet."}
|
||||
}
|
||||
if row.Protocol == ProtocolExchange {
|
||||
return testExchange(ctx, row)
|
||||
}
|
||||
if row.Kind == KindSearch {
|
||||
return testSearch(ctx, row)
|
||||
}
|
||||
return testDownload(ctx, row)
|
||||
}
|
||||
|
||||
// testExchange runs each job the row has switched on and reports them all.
|
||||
func testExchange(ctx context.Context, row dbq.AcquisitionService) TestResult {
|
||||
var parts []string
|
||||
ok := true
|
||||
run := func(name string, on bool, test func() TestResult) {
|
||||
if !on {
|
||||
return
|
||||
}
|
||||
r := test()
|
||||
ok = ok && r.OK
|
||||
parts = append(parts, name+": "+r.Detail)
|
||||
}
|
||||
run("Downloads", row.ServesDownloads, func() TestResult { return testDownload(ctx, row) })
|
||||
run("Search", row.ServesSearch, func() TestResult { return testSearch(ctx, row) })
|
||||
run("Release details", row.ServesEnrichment, func() TestResult { return testEnrichment(ctx, row) })
|
||||
if len(parts) == 0 {
|
||||
return TestResult{Detail: "Every job of this FabledExchange is switched off, so there is nothing to test."}
|
||||
}
|
||||
return TestResult{OK: ok, Detail: strings.Join(parts, " ")}
|
||||
}
|
||||
|
||||
// testSearch runs a real search.
|
||||
func testSearch(ctx context.Context, row dbq.AcquisitionService) TestResult {
|
||||
src := SourceFor(row)
|
||||
results, err := src.Search.Search(ctx, indexer.Query{Text: testQuery, Categories: indexer.MusicCategories})
|
||||
switch {
|
||||
case errors.Is(err, indexer.ErrUnconfigured):
|
||||
return TestResult{Detail: "Add this service's address, and the API key it shows on its own settings page."}
|
||||
case errors.Is(err, indexer.ErrAuth):
|
||||
return TestResult{Detail: indexer.FirstLine(err.Error()) + ". Check it on the service's own settings page."}
|
||||
case err != nil:
|
||||
return TestResult{Detail: indexer.FirstLine(err.Error())}
|
||||
case len(results) == 0:
|
||||
return TestResult{OK: true, Detail: "Connected, but a test search for music found nothing. Check that it has an indexer with music categories."}
|
||||
}
|
||||
return TestResult{OK: true, Detail: fmt.Sprintf("Connected. %d results for a test search.", len(results))}
|
||||
}
|
||||
|
||||
// testDownload lists the client's torrents, which proves the session and not
|
||||
// only the login, then checks the downloads folder from Minstrel's side.
|
||||
func testDownload(ctx context.Context, row dbq.AcquisitionService) TestResult {
|
||||
c, err := DownloadClient(row)
|
||||
if err != nil {
|
||||
return TestResult{Detail: err.Error()}
|
||||
}
|
||||
ts, err := c.Torrents(ctx)
|
||||
if err != nil {
|
||||
return TestResult{Detail: indexer.FirstLine(err.Error())}
|
||||
}
|
||||
detail := fmt.Sprintf("Connected. %d torrents.", len(ts))
|
||||
if row.Category != "" {
|
||||
detail = fmt.Sprintf("Connected. %d torrents in the %q category.", len(ts), row.Category)
|
||||
}
|
||||
if up, err := c.Connected(ctx); err == nil && !up {
|
||||
detail += " The client reports it is not connected to any peers, so downloads will stall."
|
||||
}
|
||||
if problem := landingProblem(row.LocalPath); problem != "" {
|
||||
return TestResult{Detail: detail + " " + problem}
|
||||
}
|
||||
return TestResult{OK: true, Detail: detail}
|
||||
}
|
||||
|
||||
// landingProblem checks Minstrel can see and write the downloads folder: it
|
||||
// moves finished downloads out of it. Only the local path can be checked;
|
||||
// the client's own name for it cannot be seen from here.
|
||||
func landingProblem(dir string) string {
|
||||
info, err := os.Stat(dir)
|
||||
switch {
|
||||
case err != nil:
|
||||
return fmt.Sprintf("But Minstrel cannot see its downloads folder at %s. Mount the download client's downloads folder there, or change where Minstrel looks for it.", dir)
|
||||
case !info.IsDir():
|
||||
return fmt.Sprintf("But %s is a file, not the downloads folder.", dir)
|
||||
}
|
||||
probe, err := os.CreateTemp(dir, ".minstrel-probe-*")
|
||||
if err != nil {
|
||||
return fmt.Sprintf("But Minstrel cannot write to %s, so it could not move a finished download out of it. Check the mount is not read-only and Minstrel's user can write there.", dir)
|
||||
}
|
||||
name := probe.Name()
|
||||
_ = probe.Close()
|
||||
_ = os.Remove(name)
|
||||
return ""
|
||||
}
|
||||
|
||||
// testEnrichment asks about a hash no torrent has. A JSON "not crawled" is a
|
||||
// healthy answer; a 404 from something that is not Exchange is not.
|
||||
func testEnrichment(ctx context.Context, row dbq.AcquisitionService) TestResult {
|
||||
_, err := ExchangeFor(row).Torrent(ctx, strings.Repeat("0", 40))
|
||||
switch {
|
||||
case err == nil, errors.Is(err, exchange.ErrUnknown):
|
||||
return TestResult{OK: true, Detail: "Connected. The index answered."}
|
||||
case errors.Is(err, exchange.ErrUnconfigured):
|
||||
return TestResult{Detail: "No address is set, so there is nothing to reach yet."}
|
||||
}
|
||||
return TestResult{Detail: indexer.FirstLine(err.Error())}
|
||||
}
|
||||
Reference in New Issue
Block a user