Files
yellowjacket/backend/download/manager.go
T
yonluandClaude Sonnet 5 65333857e2
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s
refactor(download): rename Want/Request to Request/Download, unify downloads flow, add auto-download guardrails
The durable "I asked for this" record was called Want, and the one-shot
search-and-grab attempt was called Request — names that didn't match
what either actually did. Want is now Request, and the old Request/Item
is now Download/DownloadItem, with a table-rename migration
(download_wants -> download_requests, old download_requests ->
download_downloads) safe against both fresh installs and existing data.

Every anchored manual download now upserts/reuses a durable Request
before running, so a "download now" that finds nothing is picked up by
the background reconciler automatically instead of just failing with
no trace — the gap that caused this session's repeated "no candidates
found" failures on the same album.

Also adds auto-download guardrails (file-size min/max with a preferred
target, allowed file types) that gate what the pipeline may grab
unattended, live-editable from a new settings section. The frontend's
wanted-view becomes downloads-view, with a new Downloads tab showing
attempt/transfer history that previously had no UI at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
2026-08-10 14:35:57 -04:00

1266 lines
31 KiB
Go

package download
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"strconv"
"sync"
"time"
"yellowjacket/backend/jobs"
)
// Manager owns the download pipeline: it builds providers from stored
// config, fans a request out across them, ranks what comes back, drives
// the chosen candidate through grab → verify → tag → import, and
// reports the whole thing as one job.
//
// One job per request, not per file. The user asked for an album; the
// fact that it arrives as twelve transfers is an implementation detail
// they should not have to read a job list to understand.
// Timeouts and limits.
const (
// searchTimeout bounds one provider's search. The fan-out takes
// whatever returned in time rather than blocking on the slowest —
// a wedged Prowlarr indexer must not stall a Soulseek result that
// arrived in 200ms.
searchTimeout = 25 * time.Second
// grabTimeout bounds one transfer. Soulseek queues are measured in
// hours when a peer is busy, so this is generous by design.
grabTimeout = 6 * time.Hour
// pollInterval is how often delegating managers are asked for
// status.
pollInterval = 15 * time.Second
// delegateTimeout bounds how long we wait for a delegate to finish
// before giving up and telling the user to check that system.
delegateTimeout = 12 * time.Hour
// defaultConcurrency bounds simultaneous grabs across all
// providers. Soulseek peers queue or ban on parallel requests, so
// the default is deliberately low.
defaultConcurrency = 2
)
// concurrencyKey is the per-provider setting that overrides its kind's
// default transfer limit.
const concurrencyKey = "maxConcurrent"
// kindConcurrency is the default number of simultaneous transfers each
// provider kind will tolerate.
//
// A single global cap is the wrong shape here: usenet and torrent
// clients are built to run many transfers at once and are throttled by
// bandwidth, while Soulseek transfers come from one person's home
// upload slot. Hitting the same peer with parallel requests gets you
// queued behind everyone else at best and banned at worst, so slskd is
// capped at one — the polite number, and the one that actually
// completes fastest, because a Soulseek peer serves one file at a time
// regardless of how many you ask for.
var kindConcurrency = map[Kind]int{
KindSlskd: 1,
KindYtDlp: 2,
KindQBittorrent: 4,
KindSABnzbd: 4,
KindProwlarr: 4,
KindLidarr: 4,
KindFake: 4,
}
// concurrencyFor returns a provider's transfer limit: its configured
// override, else its kind's default, else the global default.
func concurrencyFor(cfg Config) int {
if raw, ok := cfg.Settings[concurrencyKey]; ok && raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
return n
}
}
if n, ok := kindConcurrency[cfg.Kind]; ok {
return n
}
return defaultConcurrency
}
// Manager errors.
var (
// ErrNoProviders means nothing is configured and enabled.
ErrNoProviders = errors.New("no download providers are enabled")
// ErrNoCandidates means every provider searched and found nothing.
ErrNoCandidates = errors.New("no candidates found")
// ErrCandidateGone means the chosen candidate is no longer in the
// request's result set — usually a stale UI.
ErrCandidateGone = errors.New("candidate is no longer available")
// ErrDelegateFailed means an external manager reported that it
// could not fulfil the request.
ErrDelegateFailed = errors.New("delegate reported failure")
)
// Manager coordinates the download subsystem.
type Manager struct {
logger *slog.Logger
store *Store
secrets SecretStore
staging *Staging
importer *Importer
library LibraryPort
// jobsReg is optional; without it downloads still work but do not
// appear in the background jobs panel.
jobsReg *jobs.Registry
// opts describes the library layout imports follow.
optsMu sync.RWMutex
opts ImportOptions
// prefs gates and scores what auto-pick may grab without asking.
prefsMu sync.RWMutex
prefs AutoDownloadPrefs
// providers caches built provider instances by config ID. Rebuilt
// whenever config changes, so a settings edit takes effect without
// a restart.
provMu sync.RWMutex
providers map[int64]Provider
configs map[int64]Config
// results holds the ranked candidates of live requests, so the
// picker can be reopened without re-searching.
resMu sync.RWMutex
results map[string][]Candidate
// active tracks cancel functions for in-flight requests.
actMu sync.Mutex
active map[string]context.CancelFunc
// sem bounds concurrent grabs across every provider.
sem chan struct{}
// provSem bounds concurrent grabs per transporting provider,
// rebuilt on Reload alongside the providers themselves. A grab
// takes its provider's slot before the global one, so a queue on a
// busy Soulseek daemon cannot sit on a global slot that a usenet
// transfer could have used.
semMu sync.Mutex
provSem map[int64]chan struct{}
// delegatePoll is how often delegating managers are asked for
// status. A field rather than the constant so tests can drive the
// full delegate flow without sleeping through it.
delegatePoll time.Duration
}
// NewManager builds a download manager. Providers are not constructed
// until Reload is called, so a manager can be created before the Wails
// runtime exists.
func NewManager(
logger *slog.Logger,
store *Store,
secrets SecretStore,
staging *Staging,
importer *Importer,
library LibraryPort,
) *Manager {
return &Manager{
logger: logger,
store: store,
secrets: secrets,
staging: staging,
importer: importer,
library: library,
providers: map[int64]Provider{},
configs: map[int64]Config{},
results: map[string][]Candidate{},
active: map[string]context.CancelFunc{},
sem: make(chan struct{}, defaultConcurrency),
provSem: map[int64]chan struct{}{},
delegatePoll: pollInterval,
}
}
// SetJobRegistry wires the background jobs panel.
func (m *Manager) SetJobRegistry(reg *jobs.Registry) {
m.jobsReg = reg
}
// SetImportOptions configures the library layout imports follow.
func (m *Manager) SetImportOptions(opts ImportOptions) {
m.optsMu.Lock()
defer m.optsMu.Unlock()
m.opts = opts
}
// importOptions returns the current layout options.
func (m *Manager) importOptions() ImportOptions {
m.optsMu.RLock()
defer m.optsMu.RUnlock()
return m.opts
}
// SetPreferences configures the auto-download guardrails: the size
// window and format list AutoPickable is allowed to grab without
// asking. Live-settable so a settings change takes effect immediately,
// the same way SetImportOptions does.
func (m *Manager) SetPreferences(prefs AutoDownloadPrefs) {
m.prefsMu.Lock()
defer m.prefsMu.Unlock()
m.prefs = prefs
}
// preferences returns the current auto-download guardrails.
func (m *Manager) preferences() AutoDownloadPrefs {
m.prefsMu.RLock()
defer m.prefsMu.RUnlock()
return m.prefs
}
// AutoPickable wraps the package function with this manager's current
// preferences, so callers do not need direct field access to apply the
// live guardrails.
func (m *Manager) AutoPickable(dl Download, ranked []Candidate) bool {
return AutoPickable(dl, ranked, m.preferences())
}
// Reload rebuilds every provider from stored config. Called at startup
// and after any provider settings change.
//
// A provider that fails to build is logged and skipped rather than
// failing the reload: one misconfigured client must not disable the
// others.
func (m *Manager) Reload(ctx context.Context) error {
configs, err := m.store.ListProviders(ctx)
if err != nil {
return err
}
built := make(map[int64]Provider, len(configs))
kept := make(map[int64]Config, len(configs))
for _, cfg := range configs {
kept[cfg.ID] = cfg
if !cfg.Enabled {
continue
}
p, err := New(cfg, lookupFor(m.secrets, cfg.ID), m.logger)
if err != nil {
m.logger.Warn(
"could not build download provider",
"provider", cfg.Name,
"kind", cfg.Kind,
"error", err,
)
continue
}
built[cfg.ID] = p
}
m.provMu.Lock()
old := m.providers
m.providers = built
m.configs = kept
m.provMu.Unlock()
m.syncSemaphores(kept)
for id, p := range old {
if _, reused := built[id]; reused {
continue
}
if err := p.Close(); err != nil {
m.logger.Debug(
"error closing replaced provider", "id", id, "error", err,
)
}
}
return nil
}
// Sweep cleans staging directories left by a previous run. Called at
// startup after the store is available.
func (m *Manager) Sweep(ctx context.Context) {
live, err := m.store.ListLiveItems(ctx)
if err != nil {
m.logger.Warn("could not list live download items", "error", err)
return
}
// Anything the database still thinks is live cannot be resumed: the
// transports do not survive a restart. Mark them failed so the UI
// does not show a phantom transfer, then let staging be swept.
liveIDs := make(map[string]bool, len(live))
for _, item := range live {
liveIDs[item.ID] = true
if err := m.store.SetItemState(
ctx, item.ID, StateFailed, "interrupted by restart",
); err != nil {
m.logger.Warn(
"could not fail interrupted download item",
"item", item.ID, "error", err,
)
}
if err := m.store.SetDownloadState(
ctx, item.DownloadID, StateFailed, "interrupted by restart",
); err != nil {
m.logger.Warn(
"could not fail interrupted download request",
"request", item.DownloadID, "error", err,
)
}
}
if _, err := m.staging.Sweep(); err != nil {
m.logger.Warn("could not sweep staging directory", "error", err)
}
if _, err := m.staging.SweepOrphans(map[string]bool{}); err != nil {
m.logger.Warn("could not sweep orphaned staging dirs", "error", err)
}
}
// enabledProviders returns a snapshot of built providers with their
// configs.
func (m *Manager) enabledProviders() map[int64]Provider {
m.provMu.RLock()
defer m.provMu.RUnlock()
out := make(map[int64]Provider, len(m.providers))
for id, p := range m.providers {
out[id] = p
}
return out
}
// SetMaxConcurrent sets the global transfer limit. Called once at
// startup from the user's config; a change takes effect for transfers
// that start afterwards, since a transfer already running holds a slot
// in the semaphore it acquired.
func (m *Manager) SetMaxConcurrent(n int) {
if n <= 0 {
n = defaultConcurrency
}
m.semMu.Lock()
defer m.semMu.Unlock()
m.sem = make(chan struct{}, n)
}
// globalSem returns the current global semaphore. Callers must hold on
// to what they get: releasing into a semaphore that was replaced in the
// meantime would return a slot to the wrong pool.
func (m *Manager) globalSem() chan struct{} {
m.semMu.Lock()
defer m.semMu.Unlock()
return m.sem
}
// semaphoreFor returns a provider's own transfer semaphore, creating it
// on first use from that provider's configured or default limit.
func (m *Manager) semaphoreFor(id int64) chan struct{} {
m.provMu.RLock()
cfg, known := m.configs[id]
m.provMu.RUnlock()
m.semMu.Lock()
defer m.semMu.Unlock()
if sem, ok := m.provSem[id]; ok {
return sem
}
limit := defaultConcurrency
if known {
limit = concurrencyFor(cfg)
}
sem := make(chan struct{}, limit)
m.provSem[id] = sem
return sem
}
// syncSemaphores drops semaphores for providers that no longer exist
// and for providers whose limit changed. Transfers already holding a
// slot keep their own reference to the old channel, so replacing the
// map entry cannot strand them; it only means the new limit applies
// from the next transfer on.
func (m *Manager) syncSemaphores(configs map[int64]Config) {
m.semMu.Lock()
defer m.semMu.Unlock()
for id, sem := range m.provSem {
cfg, ok := configs[id]
if !ok {
delete(m.provSem, id)
continue
}
if cap(sem) != concurrencyFor(cfg) {
delete(m.provSem, id)
}
}
}
// listers returns every enabled provider that keeps a persistent
// list of its own, keyed by provider ID.
func (m *Manager) listers() map[int64]Lister {
m.provMu.RLock()
defer m.provMu.RUnlock()
out := map[int64]Lister{}
for id, p := range m.providers {
if l, ok := asLister(p); ok {
out[id] = l
}
}
return out
}
// priorityFor returns a provider's configured priority.
func (m *Manager) priorityFor(id int64) int {
m.provMu.RLock()
defer m.provMu.RUnlock()
if cfg, ok := m.configs[id]; ok {
return cfg.Priority
}
return 50
}
// Search fans a request out across every enabled searching provider and
// returns ranked candidates. Providers are searched concurrently with
// a per-provider timeout; a provider that errors or times out is logged
// and skipped, because partial results beat no results.
func (m *Manager) Search(
ctx context.Context,
dl Download,
) ([]Candidate, error) {
providers := m.enabledProviders()
if len(providers) == 0 {
return nil, ErrNoProviders
}
type found struct {
candidates []Candidate
err error
id int64
}
results := make(chan found)
searched := 0
for id, p := range providers {
s, ok := asSearcher(p)
if !ok {
continue
}
searched++
go func(id int64, s Searcher) {
sctx, cancel := context.WithTimeout(ctx, searchTimeout)
defer cancel()
c, err := s.Search(sctx, dl)
results <- found{candidates: c, err: err, id: id}
}(id, s)
}
if searched == 0 {
return nil, fmt.Errorf("%w: none can search", ErrNoProviders)
}
all := make([]Candidate, 0, searched*8)
for range searched {
r := <-results
if r.err != nil {
m.logger.Warn(
"download provider search failed",
"provider", r.id,
"error", r.err,
)
continue
}
for i := range r.candidates {
r.candidates[i].ProviderID = r.id
}
all = append(all, r.candidates...)
}
if len(all) == 0 {
return nil, ErrNoCandidates
}
return Rank(dl, all, m.priorityFor, m.preferences()), nil
}
// Start creates a request, searches for it, and either grabs the clear
// winner automatically or parks the ranked list for the user to pick
// from. It returns as soon as the search completes; the transfer runs
// in the background under a job.
func (m *Manager) Start(
ctx context.Context,
dl Download,
) ([]Candidate, error) {
if dl.ID == "" {
dl.ID = newID()
}
if err := m.store.CreateDownload(ctx, dl); err != nil {
return nil, err
}
job := m.startJob(dl)
ranked, err := m.Search(ctx, dl)
if err != nil {
m.failDownload(ctx, job, dl.ID, err)
return nil, err
}
m.resMu.Lock()
m.results[dl.ID] = ranked
m.resMu.Unlock()
if err := m.store.SetDownloadState(
ctx, dl.ID, StateFound, "",
); err != nil {
m.logger.Warn("could not record found state", "error", err)
}
if job != nil {
job.Logf(jobs.LevelInfo, fmt.Sprintf(
"Found %d candidates across enabled providers", len(ranked),
))
}
if m.AutoPickable(dl, ranked) {
if job != nil {
job.Logf(jobs.LevelInfo, "Auto-selected best candidate")
}
go m.grab(context.WithoutCancel(ctx), dl, ranked[0], job)
return ranked, nil
}
if job != nil {
job.SetPhase("Waiting for you to pick")
job.SetState(jobs.StatePaused)
}
return ranked, nil
}
// Attempt searches on behalf of the request list and starts a download
// only if there is a clear winner. It returns whether it started and,
// when it did not, a sentence the request list can show the user.
//
// Unlike Start it persists nothing when it does not act. A request that
// is retried weekly for a year would otherwise leave fifty failed
// download rows behind it, all saying the same thing the request itself
// already says — and none of them anything the user can do something
// about. Nobody is watching a reconcile pass, so the only two honest
// outcomes are "downloading it now" and "still looking".
func (m *Manager) Attempt(
ctx context.Context,
dl Download,
) (bool, string, error) {
if dl.ID == "" {
dl.ID = newID()
}
ranked, err := m.Search(ctx, dl)
if err != nil {
return false, "", err
}
if !m.AutoPickable(dl, ranked) {
best := ranked[0]
return false, fmt.Sprintf(
"best of %d found is not a confident enough match "+
"(match %.0f%%, quality %.0f%%)",
len(ranked),
best.Match.Overall*100, //nolint:mnd // percent
best.Quality.Overall*100,
), nil
}
if err := m.store.CreateDownload(ctx, dl); err != nil {
return false, "", err
}
m.resMu.Lock()
m.results[dl.ID] = ranked
m.resMu.Unlock()
if err := m.store.SetDownloadState(ctx, dl.ID, StateFound, ""); err != nil {
m.logger.Warn("could not record found state", "error", err)
}
job := m.startJob(dl)
if job != nil {
job.Logf(jobs.LevelInfo, fmt.Sprintf(
"Request list: auto-selected the best of %d candidates",
len(ranked),
))
}
go m.grab(context.WithoutCancel(ctx), dl, ranked[0], job)
return true, "", nil
}
// Pick starts the transfer for a candidate the user chose.
func (m *Manager) Pick(
ctx context.Context,
downloadID, candidateID string,
) error {
dl, err := m.store.GetDownload(ctx, downloadID)
if err != nil {
return err
}
m.resMu.RLock()
ranked := m.results[downloadID]
m.resMu.RUnlock()
var chosen *Candidate
for i := range ranked {
if ranked[i].ID == candidateID {
chosen = &ranked[i]
break
}
}
if chosen == nil {
return fmt.Errorf("%w: %s", ErrCandidateGone, candidateID)
}
job := m.startJob(dl)
go m.grab(context.WithoutCancel(ctx), dl, *chosen, job)
return nil
}
// Cancel aborts a live request.
func (m *Manager) Cancel(ctx context.Context, downloadID string) error {
m.actMu.Lock()
cancel, ok := m.active[downloadID]
m.actMu.Unlock()
if ok {
cancel()
}
if err := m.store.SetDownloadState(
ctx, downloadID, StateCancelled, "",
); err != nil {
return err
}
return nil
}
// grab drives one candidate all the way to the library. It runs on its
// own goroutine and owns the job from here on.
func (m *Manager) grab(
ctx context.Context,
dl Download,
c Candidate,
job *jobs.Handle,
) {
ctx, cancel := context.WithTimeout(ctx, grabTimeout)
defer cancel()
m.actMu.Lock()
m.active[dl.ID] = cancel
m.actMu.Unlock()
defer func() {
m.actMu.Lock()
delete(m.active, dl.ID)
m.actMu.Unlock()
}()
// Who will move the bytes is decided before any slot is taken, so
// the transfer waits in its own provider's queue rather than in a
// global one. A delegate takes no slot at all: the transfer is
// happening inside another system, which is doing its own limiting,
// and blocking a local slot on it would be counting someone else's
// work against our budget.
plan, err := m.planTransfer(dl, c)
if err != nil {
m.failDownload(ctx, job, dl.ID, err)
return
}
if !plan.delegated() {
provSem := m.semaphoreFor(plan.transportID)
select {
case provSem <- struct{}{}:
defer func() { <-provSem }()
case <-ctx.Done():
m.failDownload(ctx, job, dl.ID, ctx.Err())
return
}
globalSem := m.globalSem()
select {
case globalSem <- struct{}{}:
defer func() { <-globalSem }()
case <-ctx.Done():
m.failDownload(ctx, job, dl.ID, ctx.Err())
return
}
}
item := DownloadItem{
ID: newID(),
DownloadID: dl.ID,
ProviderID: c.ProviderID,
Candidate: c,
State: StateQueued,
BytesTotal: c.TotalSize,
}
dir, err := m.staging.Reserve(item.ID)
if err != nil {
m.failDownload(ctx, job, dl.ID, err)
return
}
item.StagingDir = dir
if err := m.store.CreateItem(ctx, item); err != nil {
m.failDownload(ctx, job, dl.ID, err)
return
}
result, err := m.transfer(ctx, dl, item, plan, job)
if err != nil {
m.failItem(ctx, job, item, dl.ID, err)
return
}
m.setStates(ctx, dl.ID, item.ID, StateImporting)
if job != nil {
job.SetPhase("Importing")
job.SetStages(importStages(2))
}
var imported ImportResult
if result.Delegated {
// The external manager already placed and tagged these files in
// its own library. Moving them out from under a system that is
// still managing them would be worse than useless, so the files
// are recorded where they are and the library scan picks them
// up in place.
imported = ImportResult{Paths: result.Files}
if job != nil {
job.Logf(jobs.LevelInfo, fmt.Sprintf(
"External manager imported %d files; recording them in place",
len(result.Files),
))
}
} else {
opts := m.importOptions()
opts.WriteTags = true
opts.LibraryRoot, err = m.library.LibraryPath(dl.LibraryID)
if err != nil {
m.failItem(ctx, job, item, dl.ID,
fmt.Errorf("resolve library root: %w", err))
return
}
imported, err = m.importer.Import(ctx, dl, result, opts)
if err != nil {
m.failItem(ctx, job, item, dl.ID, err)
return
}
}
if err := m.store.SetItemImported(
ctx, item.ID, imported.Paths,
); err != nil {
m.logger.Warn("could not record imported paths", "error", err)
}
if err := m.store.SetDownloadState(
ctx, dl.ID, StateComplete, "",
); err != nil {
m.logger.Warn("could not record complete state", "error", err)
}
// A download raised from a durable Request retires it here
// rather than waiting for the next reconcile pass to notice the
// files, so the request list is right the moment the download
// finishes. The pass would reach the same conclusion by asking the
// library; this is the same answer, sooner.
if dl.RequestID != 0 {
if err := m.store.SatisfyRequest(ctx, dl.RequestID); err != nil {
m.logger.Warn(
"could not satisfy request", "request", dl.RequestID, "error", err,
)
}
}
// The ranked list only existed so the picker could be reopened
// mid-flight. Holding it after the download completes would leak a
// few hundred candidates per request for the life of the process.
m.resMu.Lock()
delete(m.results, dl.ID)
m.resMu.Unlock()
// Staging is only released on a fully successful import; a failure
// leaves the files for retry or inspection.
if err := m.staging.Release(item.StagingDir); err != nil {
m.logger.Warn("could not release staging dir", "error", err)
}
if m.library != nil {
if err := m.library.ScanLibrary(dl.LibraryID); err != nil {
m.logger.Warn(
"could not trigger scan after import",
"library", dl.LibraryID,
"error", err,
)
}
}
if job != nil {
job.SetStats([]jobs.Stat{
{Label: "Imported", Value: itoa(len(imported.Paths))},
{Label: "Tagged", Value: itoa(imported.Tagged)},
})
job.Logf(jobs.LevelInfo, fmt.Sprintf(
"Imported %d files into the library", len(imported.Paths),
))
job.Complete()
}
}
// transfer moves the bytes, dispatching on whether the candidate's
// provider fetches its own results, needs a separate transport, or
// delegates the whole thing.
func (m *Manager) transfer(
ctx context.Context,
dl Download,
item DownloadItem,
plan transferPlan,
job *jobs.Handle,
) (Result, error) {
if plan.delegated() {
return m.delegate(ctx, dl, item, plan.delegate, job)
}
m.setStates(ctx, dl.ID, item.ID, StateGrabbing)
if job != nil {
job.SetPhase("Downloading")
job.SetProgress(0, item.Candidate.TotalSize)
}
onProgress := m.progressReporter(ctx, item.ID, job)
result, err := plan.transport.Grab(
ctx, item.Candidate, item.StagingDir, onProgress,
)
if err != nil {
return Result{}, fmt.Errorf("grab failed: %w", err)
}
m.setStates(ctx, dl.ID, item.ID, StateVerifying)
if job != nil {
job.SetPhase("Verifying")
}
return result, nil
}
// transportFor picks the transport that will fetch a candidate: the
// finding provider itself when it can, otherwise the highest-priority
// enabled provider that handles the candidate's protocol.
func (m *Manager) transportFor(
providers map[int64]Provider,
sourceID int64,
source Provider,
c Candidate,
) (Transporter, int64, error) {
if c.Protocol == ProtocolDirect {
t, ok := asTransporter(source)
if !ok {
return nil, 0, fmt.Errorf(
"%w: %s cannot fetch its own results",
ErrUnsupported, source.Info().Kind,
)
}
return t, sourceID, nil
}
var (
best Transporter
bestID int64
bestPrio = -1
)
for id, p := range providers {
t, ok := asTransporter(p)
if !ok || !p.Info().Caps.Handles(c.Protocol) {
continue
}
if prio := m.priorityFor(id); prio > bestPrio {
best, bestID, bestPrio = t, id, prio
}
}
if best == nil {
return nil, 0, fmt.Errorf("%w: %s", ErrNoTransport, c.Protocol)
}
return best, bestID, nil
}
// transferPlan is who will move a candidate's bytes, resolved before
// any concurrency slot is taken so a transfer queues against the
// provider that will actually do the work.
type transferPlan struct {
// delegate is set when an external manager owns the whole transfer.
delegate Delegator
// transport and transportID are set otherwise.
transport Transporter
transportID int64
}
// delegated reports whether this plan hands the work to another system.
func (p transferPlan) delegated() bool { return p.delegate != nil }
// planTransfer decides how a candidate will be fetched.
func (m *Manager) planTransfer(_ Download, c Candidate) (transferPlan, error) {
providers := m.enabledProviders()
source, ok := providers[c.ProviderID]
if !ok {
return transferPlan{}, fmt.Errorf(
"%w: provider %d", ErrNotConfigured, c.ProviderID,
)
}
if d, ok := asDelegator(source); ok {
return transferPlan{delegate: d}, nil
}
transport, id, err := m.transportFor(providers, c.ProviderID, source, c)
if err != nil {
return transferPlan{}, err
}
return transferPlan{transport: transport, transportID: id}, nil
}
// delegate hands the request to an external manager and polls until it
// reports terminal state.
func (m *Manager) delegate(
ctx context.Context,
dl Download,
item DownloadItem,
d Delegator,
job *jobs.Handle,
) (Result, error) {
externalID, err := d.Delegate(ctx, dl)
if err != nil {
return Result{}, fmt.Errorf("delegate request: %w", err)
}
if err := m.store.SetItemExternalID(ctx, item.ID, externalID); err != nil {
m.logger.Warn("could not record external id", "error", err)
}
m.setStates(ctx, dl.ID, item.ID, StateGrabbing)
if job != nil {
job.SetPhase("Waiting on external manager")
job.Logf(jobs.LevelInfo, "Handed request to "+string(item.Candidate.Kind))
}
ctx, cancel := context.WithTimeout(ctx, delegateTimeout)
defer cancel()
ticker := time.NewTicker(m.delegatePoll)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
_ = d.Withdraw(context.WithoutCancel(ctx), externalID)
return Result{}, fmt.Errorf("delegate timed out: %w", ctx.Err())
case <-ticker.C:
}
status, err := d.Poll(ctx, externalID)
if err != nil {
m.logger.Warn("delegate poll failed", "error", err)
continue
}
if job != nil && status.Progress >= 0 {
job.SetProgress(int64(status.Progress*100), 100)
}
switch status.State {
case StateComplete:
// The manager placed the files itself, so there is nothing
// in staging and nothing for us to move. Report its paths
// so the item records what landed where.
return Result{
Files: status.ImportedPaths,
Delegated: true,
}, nil
case StateFailed, StateCancelled:
return Result{}, fmt.Errorf(
"%w: %s: %s", ErrDelegateFailed, status.State, status.Message,
)
case StateSearching, StateFound, StateQueued, StateGrabbing,
StateVerifying, StateTagging, StateImporting:
// Still working.
}
}
}
// progressReporter returns a throttled ProgressFunc that updates both
// the job and the stored item. Transports call this per chunk, so it
// must be cheap: the job registry already coalesces, but a database
// write per chunk would not survive contact with a fast transfer.
func (m *Manager) progressReporter(
ctx context.Context,
itemID string,
job *jobs.Handle,
) ProgressFunc {
const dbInterval = 3 * time.Second
var (
mu sync.Mutex
lastSave time.Time
)
return func(p Progress) {
if job != nil {
job.SetProgress(p.Current, p.Total)
if p.Phase != "" {
job.SetPhase(p.Phase)
}
}
mu.Lock()
if time.Since(lastSave) < dbInterval {
mu.Unlock()
return
}
lastSave = time.Now()
mu.Unlock()
if err := m.store.SetItemProgress(
ctx, itemID, p.Current, p.Total,
); err != nil {
m.logger.Debug("could not save item progress", "error", err)
}
}
}
// Candidates returns the ranked candidates for a live request.
func (m *Manager) Candidates(downloadID string) []Candidate {
m.resMu.RLock()
defer m.resMu.RUnlock()
out := make([]Candidate, len(m.results[downloadID]))
copy(out, m.results[downloadID])
return out
}
// setStates advances a request and its item together.
func (m *Manager) setStates(
ctx context.Context,
downloadID, itemID string,
state State,
) {
if err := m.store.SetDownloadState(ctx, downloadID, state, ""); err != nil {
m.logger.Warn("could not set request state", "error", err)
}
if err := m.store.SetItemState(ctx, itemID, state, ""); err != nil {
m.logger.Warn("could not set item state", "error", err)
}
}
// failDownload records a download-level failure.
func (m *Manager) failDownload(
ctx context.Context,
job *jobs.Handle,
downloadID string,
err error,
) {
m.logger.Warn("download failed", "download", downloadID, "error", err)
if serr := m.store.SetDownloadState(
ctx, downloadID, StateFailed, err.Error(),
); serr != nil {
m.logger.Warn("could not record failure", "error", serr)
}
if job != nil {
job.Fail(err)
}
}
// failItem records an item-level failure and fails its download.
func (m *Manager) failItem(
ctx context.Context,
job *jobs.Handle,
item DownloadItem,
downloadID string,
err error,
) {
if serr := m.store.SetItemState(
ctx, item.ID, StateFailed, err.Error(),
); serr != nil {
m.logger.Warn("could not record item failure", "error", serr)
}
m.failDownload(ctx, job, downloadID, err)
}
// startJob registers the request in the background jobs panel.
func (m *Manager) startJob(dl Download) *jobs.Handle {
if m.jobsReg == nil {
return nil
}
title := dl.Album
if title == "" {
title = dl.SearchText()
}
return m.jobsReg.Start(jobs.Spec{
ID: "download-" + dl.ID,
Kind: jobs.KindDownload,
Title: "Downloading " + title,
Subtitle: dl.Artist,
State: jobs.StateRunning,
Caps: jobs.Caps{Cancellable: true},
Controls: jobs.Controls{
Cancel: func() {
if err := m.Cancel(context.Background(), dl.ID); err != nil {
m.logger.Warn("cancel failed", "error", err)
}
},
},
})
}
// importStages renders the pipeline tail as job stages.
func importStages(done int) []jobs.Stage {
names := []string{"Search", "Download", "Import"}
out := make([]jobs.Stage, 0, len(names))
for i, n := range names {
state := "pending"
switch {
case i < done:
state = "complete"
case i == done:
state = "running"
}
out = append(out, jobs.Stage{Name: n, State: state})
}
return out
}
// newID returns a random identifier for a request or item.
func newID() string {
var b [12]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand failing means the system is in a state where a
// timestamp fallback is the least of anyone's problems, but a
// collision here would silently merge two downloads.
return "dl-" + time.Now().Format("20060102150405.000000000")
}
return hex.EncodeToString(b[:])
}
// itoa formats an int for job stats.
func itoa(n int) string {
return strconv.Itoa(n)
}