Files
minstrel/internal/acquire/settings.go
T
bvandeusenandClaude Opus 5.5 8245f4406e
release / govulncheck (push) Successful in 17s
release / web (push) Failing after 17s
release / go (push) Successful in 1m11s
release / integration (push) Successful in 4m50s
release / android (push) Successful in 4m54s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
feat: download lifecycle — stalled and failed downloads are given up and searched again; imported ones seed, then go (M502 #5523)
Ported from Fabled Librarian's System 104. A new lifecycle poller, every
ten minutes:
- a download the client fails is given up at once;
- one nobody shares, or still fetching its file list, past the stall
  setting (6 h) is asked for again three times, then given up;
- giving up removes the download with its files, holds the release back
  for a week (release_blocks.expires_at), ends the grab and searches the
  album again, with no limit per album;
- a running download the client stops listing for half an hour ends and is
  searched again, not held back; an empty list is not taken as gone;
- an imported download seeds to ratio 2.0 or 14 days, then is removed;
- a download the client would not remove is asked again every pass.

Every condition and act is one open row per (client, download, condition)
in download_interventions. Activity shows what is wrong with a download and
how often it was retried, why one ended, its seeding, and a banner for a
client that does not answer. A running download can be given up by hand
(set aside for good). The seed ratio, seed days and stall hours are on the
search settings card. The download client lookup moves to
acquire.DownloadClientFor, shared by the importer and the lifecycle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:27:22 -04:00

187 lines
5.9 KiB
Go

// 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"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"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
}
// DownloadClientFor builds the download client a grab went to, by the code
// of its acquisition_services row.
func DownloadClientFor(ctx context.Context, pool *pgxpool.Pool, code string) (downloader.Driver, error) {
row, err := dbq.New(pool).GetAcquisitionService(ctx, code)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("the download client %q is no longer set up", code)
}
return nil, err
}
return DownloadClient(row)
}
// 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 }