Files
yellowjacket/backend/download/manager.go
yonluandClaude Opus 5.5 9710c11476
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Failing after 2m51s
CI / e2e (pull_request) Skipped
feat(download): one grab per Soulseek peer, several peers at once
slskd was capped at one transfer per daemon, on the grounds that
Soulseek peers punish clients that ask for too much. That politeness is
per peer: two different users do not compete for anyone's upload slot.
So one slow peer serialised every other Soulseek download behind it.

The manager now takes a per-(provider, peer) lock before any slot, so a
grab waiting on a busy peer does not hold a provider slot another peer
could use, and the slskd default rises to 3, which now counts peers.
The help text says so.

Running grabs at once exposed the folder collision: slskd names a
download's directory after the remote leaf folder, so two peers'
"Greatest Hits" (or any two rips' "CD1") share one directory, and
collect finds files by name there. Grabs whose local folders overlap
now take a package-level lock per folder, in sorted order, keyed on the
full path because two clients can share one daemon.

Closes #272

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT
2026-09-26 17:31:35 -04:00

1444 lines
36 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. Politeness there is per *peer* — asking one user for two
// folders at once gets you queued behind everyone else at best and
// banned at worst — and the manager holds that line separately, one
// grab per peer (peerLocks). Two different users do not compete for
// anyone's slot, so the daemon-wide number only bounds how many peers
// are asked at once, and one slow peer no longer serialises every other
// Soulseek download behind it.
var kindConcurrency = map[Kind]int{
KindSlskd: 3,
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{}
// peerLocks holds one grab per Soulseek peer, taken before any
// slot: a grab waiting for a busy peer must not sit on a provider
// slot another peer could be using.
peerLocks keyedLock[peerKey]
// 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())
}
// AutoPickVeto wraps the package function the same way, and is what the
// request list quotes back to the user.
func (m *Manager) AutoPickVeto(dl Download, ranked []Candidate) string {
return AutoPickVeto(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 pick, ok := autoPick(dl, ranked, m.preferences()); ok {
if job != nil {
job.Logf(jobs.LevelInfo, "Auto-selected best candidate")
}
go m.grab(context.WithoutCancel(ctx), dl, pick, job, true)
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 veto := m.AutoPickVeto(dl, ranked); veto != "" {
return false, veto, nil
}
pick, ok := autoPick(dl, ranked, m.preferences())
if !ok {
// Unreachable while autoPick and AutoPickVeto agree; kept so a
// future divergence refuses rather than grabbing blind.
return false, "no candidate clears the auto-download bar", 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, pick, job, true)
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, false)
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 request all the way to the library. It runs on its
// own goroutine and owns the job from here on.
//
// When fallback is set and a candidate's transfer fails, the next
// candidate that auto-pick would itself have accepted is tried in its
// place (see nextCandidate). It is set for the two unattended routes
// and not for a candidate the user picked by hand: they chose that copy,
// and quietly substituting another is a decision they did not make.
func (m *Manager) grab(
ctx context.Context,
dl Download,
c Candidate,
job *jobs.Handle,
fallback bool,
) {
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()
}()
var failed []Candidate
for {
out := m.attemptGrab(ctx, dl, c, job)
if out.err == nil {
m.finishGrab(ctx, dl, out.item, out.imported, job)
return
}
failed = append(failed, c)
next, ok := m.nextCandidate(ctx, dl, failed, out, fallback)
if !ok {
m.failDownload(ctx, job, dl.ID, out.err)
return
}
m.logger.Info(
"download candidate failed; trying the next",
"download", dl.ID,
"failed", c.ID,
"next", next.ID,
"error", out.err,
)
if job != nil {
job.Logf(jobs.LevelWarn, fmt.Sprintf(
"%s failed (%v); trying %s instead",
describeCandidate(c), out.err, describeCandidate(next),
))
}
// The failed attempt's staging holds at most a partial folder
// nobody is going to import, and the next attempt reserves its
// own. Only the final failure keeps its staging for inspection.
if out.item.StagingDir != "" {
if err := m.staging.Release(out.item.StagingDir); err != nil {
m.logger.Warn("could not release staging dir", "error", err)
}
}
c = next
}
}
// maxGrabAttempts bounds how many candidates one request will try. A
// popular album can have dozens of peers; the point of falling back is
// to survive the ordinary one or two that are offline, not to walk the
// whole list for six hours.
const maxGrabAttempts = 3
// peerKey names one Soulseek user on one daemon. The same username on
// two daemons is two logins and two queues.
type peerKey struct {
provider int64
peer string
}
// peerKeyFor returns the peer a candidate is fetched from, when the
// source is one where asking a peer for two things at once is rude.
func peerKeyFor(c Candidate) (peerKey, bool) {
if c.Kind != KindSlskd || c.Origin == "" {
return peerKey{}, false
}
return peerKey{provider: c.ProviderID, peer: c.Origin}, true
}
// grabOutcome is how one candidate's attempt ended.
type grabOutcome struct {
item DownloadItem
imported ImportResult
err error
// retryable reports whether another candidate might succeed where
// this one failed: the transfer failed, or delivered too little of
// the album. Anything else — no staging space, no library root, a
// tag write failing — would fail the next candidate identically.
retryable bool
}
// attemptGrab takes one candidate through transfer and import. It
// records the item's own failure, but not the download's: whether the
// download has failed is the caller's decision, since another candidate
// may yet succeed.
func (m *Manager) attemptGrab(
ctx context.Context,
dl Download,
c Candidate,
job *jobs.Handle,
) grabOutcome {
// 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 {
return grabOutcome{err: err}
}
if !plan.delegated() {
if key, ok := peerKeyFor(c); ok {
release, err := m.peerLocks.acquire(ctx, key)
if err != nil {
return grabOutcome{err: err}
}
defer release()
}
provSem := m.semaphoreFor(plan.transportID)
select {
case provSem <- struct{}{}:
defer func() { <-provSem }()
case <-ctx.Done():
return grabOutcome{err: ctx.Err()}
}
globalSem := m.globalSem()
select {
case globalSem <- struct{}{}:
defer func() { <-globalSem }()
case <-ctx.Done():
return grabOutcome{err: ctx.Err()}
}
}
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 {
return grabOutcome{err: err}
}
item.StagingDir = dir
if err := m.store.CreateItem(ctx, item); err != nil {
return grabOutcome{item: item, err: err}
}
fail := func(err error, retryable bool) grabOutcome {
if serr := m.store.SetItemState(
ctx, item.ID, StateFailed, err.Error(),
); serr != nil {
m.logger.Warn("could not record item failure", "error", serr)
}
return grabOutcome{item: item, err: err, retryable: retryable}
}
result, err := m.transfer(ctx, dl, item, plan, job)
if err != nil {
// A delegate's failure is the external manager's verdict on the
// whole request, not on one copy of it.
return fail(err, !plan.delegated())
}
m.setStates(ctx, dl.ID, item.ID, StateImporting)
if job != nil {
job.SetPhase("Importing")
job.SetStages(importStages(2))
}
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.
if job != nil {
job.Logf(jobs.LevelInfo, fmt.Sprintf(
"External manager imported %d files; recording them in place",
len(result.Files),
))
}
return grabOutcome{
item: item,
imported: ImportResult{Paths: result.Files},
}
}
opts := m.importOptions()
opts.WriteTags = true
opts.LibraryRoot, err = m.library.LibraryPath(dl.LibraryID)
if err != nil {
return fail(fmt.Errorf("resolve library root: %w", err), false)
}
imported, err := m.importer.Import(ctx, dl, result, opts)
if err != nil {
return fail(err, errors.Is(err, ErrTooIncomplete))
}
return grabOutcome{item: item, imported: imported}
}
// nextCandidate picks the candidate to try after the ones in failed.
//
// It only ever offers a candidate auto-pick would have taken on its own
// (autoAcceptable), so falling back cannot lower the bar an unattended
// download is held to: the second choice has to clear the same gates
// the first did.
//
// On Soulseek a failure belongs to the *peer* — offline, refusing, or
// holding us in a queue — so every folder that peer offered is skipped
// with it. Elsewhere a failure belongs to the release, and only that
// candidate is.
func (m *Manager) nextCandidate(
ctx context.Context,
dl Download,
failed []Candidate,
out grabOutcome,
fallback bool,
) (Candidate, bool) {
if !fallback || !out.retryable || ctx.Err() != nil ||
len(failed) >= maxGrabAttempts {
return Candidate{}, false
}
m.resMu.RLock()
ranked := m.results[dl.ID]
m.resMu.RUnlock()
prefs := m.preferences()
for _, c := range ranked {
if ruledOutBy(c, failed) || !autoAcceptable(dl, c, prefs) {
continue
}
return c, true
}
return Candidate{}, false
}
// ruledOutBy reports whether a failure among failed also rules out c.
func ruledOutBy(c Candidate, failed []Candidate) bool {
for _, f := range failed {
if c.ID == f.ID && c.ProviderID == f.ProviderID {
return true
}
if c.Kind == KindSlskd && f.Kind == KindSlskd &&
c.ProviderID == f.ProviderID && c.Origin != "" &&
c.Origin == f.Origin {
return true
}
}
return false
}
// describeCandidate names a candidate for the job log.
func describeCandidate(c Candidate) string {
if c.Origin != "" {
return fmt.Sprintf("%q from %s", c.Title, c.Origin)
}
return fmt.Sprintf("%q", c.Title)
}
// finishGrab records a successful import and retires what the request
// was holding.
func (m *Manager) finishGrab(
ctx context.Context,
dl Download,
item DownloadItem,
imported ImportResult,
job *jobs.Handle,
) {
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)
}
}
// 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)
}