Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.
Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
today rather than a milestone that would change it.
Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
192 lines
6.0 KiB
Go
192 lines
6.0 KiB
Go
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 an admin 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
|
|
}
|