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 }