// 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 }