Files
yellowjacket/backend/download/fake.go
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

284 lines
6.1 KiB
Go

package download
import (
"context"
"errors"
"log/slog"
"os"
"path/filepath"
"sync"
)
// The fake provider exists so the pipeline can be tested end to end
// without a network, a daemon, or a binary on PATH. It is registered
// like any real adapter and excluded from Descriptors(), so it can
// never be offered in the UI.
// FakeProvider is an in-memory Provider used by tests. It fills all
// three roles; which ones are active is controlled by its Caps.
type FakeProvider struct {
info ProviderInfo
mu sync.Mutex
// Candidates is what Search returns.
Candidates []Candidate
// SearchErr, GrabErr and CheckErr are returned when set.
SearchErr error
GrabErr error
CheckErr error
// Written maps a relative file name to the bytes Grab creates in
// the staging directory, simulating a completed transfer.
Written map[string][]byte
// DelegateStatuses is returned by Poll in order, the last repeating.
DelegateStatuses []DelegateStatus
// GrabGate, when set, blocks Grab until it is closed or the
// context ends. It lets a test hold transfers open long enough to
// observe how many run at once.
GrabGate chan struct{}
// concurrent tracks how many Grabs are in flight, and maxParallel
// the high-water mark, which is what a concurrency cap is asserted
// against.
concurrent int
maxParallel int
// Calls records what happened, for assertions.
SearchCalls int
GrabCalls int
DelegateCalls int
pollIndex int
}
// NewFakeProvider returns a fake with the given capabilities.
func NewFakeProvider(id int64, name string, caps Caps) *FakeProvider {
return &FakeProvider{
info: ProviderInfo{
ID: id,
Kind: KindFake,
Name: name,
Enabled: true,
Priority: 50,
Caps: caps,
},
Written: map[string][]byte{},
}
}
// Info returns the fake's identity.
func (f *FakeProvider) Info() ProviderInfo {
return f.info
}
// SetPriority adjusts the fake's priority.
func (f *FakeProvider) SetPriority(p int) {
f.info.Priority = p
}
// Check reports configured health.
func (f *FakeProvider) Check(_ context.Context) error {
return f.CheckErr
}
// Close is a no-op.
func (f *FakeProvider) Close() error {
return nil
}
// Search returns the configured candidates.
func (f *FakeProvider) Search(
ctx context.Context,
_ Download,
) ([]Candidate, error) {
f.mu.Lock()
f.SearchCalls++
f.mu.Unlock()
if err := ctx.Err(); err != nil {
return nil, err //nolint:wrapcheck // context error, already meaningful
}
if f.SearchErr != nil {
return nil, f.SearchErr
}
out := make([]Candidate, len(f.Candidates))
copy(out, f.Candidates)
for i := range out {
out[i].ProviderID = f.info.ID
}
return out, nil
}
// Grab writes the configured files into dst.
func (f *FakeProvider) Grab(
ctx context.Context,
_ Candidate,
dst string,
onProgress ProgressFunc,
) (Result, error) {
f.mu.Lock()
f.GrabCalls++
f.concurrent++
if f.concurrent > f.maxParallel {
f.maxParallel = f.concurrent
}
gate := f.GrabGate
f.mu.Unlock()
defer func() {
f.mu.Lock()
f.concurrent--
f.mu.Unlock()
}()
if gate != nil {
select {
case <-gate:
case <-ctx.Done():
return Result{}, ctx.Err() //nolint:wrapcheck // context error
}
}
if err := ctx.Err(); err != nil {
return Result{}, err //nolint:wrapcheck // context error
}
if f.GrabErr != nil {
return Result{}, f.GrabErr
}
files := make([]string, 0, len(f.Written))
var total int64
for name, data := range f.Written {
path := filepath.Join(dst, name)
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return Result{}, err //nolint:wrapcheck // test helper
}
if err := os.WriteFile(path, data, 0o640); err != nil {
return Result{}, err //nolint:wrapcheck // test helper
}
files = append(files, path)
total += int64(len(data))
if onProgress != nil {
onProgress(Progress{Current: total, Total: total})
}
}
return Result{Dir: dst, Files: files, BytesTransferred: total}, nil
}
// GrabCallCount reports how many transfers have been started, safe to
// read while transfers are in flight.
func (f *FakeProvider) GrabCallCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.GrabCalls
}
// MaxParallelGrabs reports the most simultaneous transfers this
// provider ever saw, which is what a per-provider cap is asserted
// against.
func (f *FakeProvider) MaxParallelGrabs() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.maxParallel
}
// errNoDelegateStatus is returned when a fake delegator runs out of
// scripted statuses.
var errNoDelegateStatus = errors.New("fake: no delegate status configured")
// Delegate records the call and returns a fixed external ID.
func (f *FakeProvider) Delegate(
_ context.Context,
_ Download,
) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.DelegateCalls++
return "fake-external-1", nil
}
// Poll returns the next scripted status, repeating the last.
func (f *FakeProvider) Poll(
_ context.Context,
_ string,
) (DelegateStatus, error) {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.DelegateStatuses) == 0 {
return DelegateStatus{}, errNoDelegateStatus
}
i := f.pollIndex
if i >= len(f.DelegateStatuses) {
i = len(f.DelegateStatuses) - 1
} else {
f.pollIndex++
}
return f.DelegateStatuses[i], nil
}
// Withdraw is a no-op.
func (f *FakeProvider) Withdraw(_ context.Context, _ string) error {
return nil
}
// fakeRegistry lets tests install providers directly, bypassing the
// database and the constructor registry.
func (m *Manager) installProvider(cfg Config, p Provider) {
m.provMu.Lock()
defer m.provMu.Unlock()
m.providers[cfg.ID] = p
m.configs[cfg.ID] = cfg
}
func init() {
Register(
Descriptor{
Kind: KindFake,
Name: "Fake (testing)",
Summary: "In-memory provider used by the test suite.",
Caps: Caps{
CanSearch: true,
CanTransport: true,
},
},
func(cfg Config, _ SecretLookup, _ *slog.Logger) (Provider, error) {
return NewFakeProvider(cfg.ID, cfg.Name, Caps{
CanSearch: true,
CanTransport: true,
}), nil
},
)
}
// slogDiscard returns a logger that writes nowhere, for tests.
func slogDiscard() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelError + 1,
}))
}