refactor(download): rename Want/Request to Request/Download, unify downloads flow, add auto-download guardrails
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s

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
This commit is contained in:
2026-08-10 14:35:57 -04:00
co-authored by Claude Sonnet 5
parent cbd82a5a74
commit 65333857e2
62 changed files with 4067 additions and 2524 deletions
+166 -96
View File
@@ -12,10 +12,10 @@ import (
"yellowjacket/backend/events"
)
// ErrNoLibrary means the request did not name a library to attach the
// download to. Letting it through would hit the download_requests /
// download_wants foreign key on library_id and surface as a raw SQLite
// error, so it is rejected here with a message the UI can show.
// ErrNoLibrary means the caller did not name a library to attach the
// download to. Letting it through would hit the download_downloads /
// download_requests foreign key on library_id and surface as a raw
// SQLite error, so it is rejected here with a message the UI can show.
var ErrNoLibrary = errors.New("no library selected")
// Service is the frontend-facing surface of the download subsystem.
@@ -258,8 +258,20 @@ func (s *Service) TestProvider(id int64) error {
return nil
}
// SetPreferences pushes the auto-download guardrails straight into the
// running Manager, without persisting them. Persistence is
// config.Config's job (GetDownloadPreferences/SetDownloadPreferences);
// this package cannot depend on config, since config already depends on
// download for UserConfig. The frontend settings save is expected to
// call the config setter and this method in the same action, the way
// UpdateProvider already achieves "live without a restart" by touching
// storage and the running Manager together.
func (s *Service) SetPreferences(prefs AutoDownloadPrefs) {
s.manager.SetPreferences(prefs)
}
// ---------------------------------------------------------------------------
// Requests
// Downloads
// ---------------------------------------------------------------------------
// SearchRequest is what the frontend submits to start a download.
@@ -275,7 +287,7 @@ type SearchRequest struct {
// StartResult is what the picker needs after a search.
type StartResult struct {
RequestID string `json:"requestId"`
DownloadID string `json:"downloadId"`
Candidates []Candidate `json:"candidates"`
// AutoPicked reports that the pipeline already chose and is
@@ -284,14 +296,21 @@ type StartResult struct {
AutoPicked bool `json:"autoPicked"`
}
// Start searches for a release and either auto-picks a clear winner or
// returns ranked candidates for the user to choose from.
func (s *Service) Start(req SearchRequest) (StartResult, error) {
// StartDownload searches for a release and either auto-picks a clear
// winner or returns ranked candidates for the user to choose from.
//
// When the search carries a MusicBrainz anchor, it also resolves or
// creates the durable Request the anchor names and attaches it, via
// ensureRequest — so a manual download that fails or finds nothing
// right now leaves a durable record behind instead of just vanishing,
// and the reconciler picks it up on its normal schedule exactly as if
// the user had explicitly added it to the request list.
func (s *Service) StartDownload(req SearchRequest) (StartResult, error) {
if req.LibraryID <= 0 {
return StartResult{}, ErrNoLibrary
}
r := Request{
dl := Download{
ID: newID(),
LibraryID: req.LibraryID,
ReleaseMBID: req.ReleaseMBID,
@@ -302,15 +321,19 @@ func (s *Service) Start(req SearchRequest) (StartResult, error) {
Expected: req.Expected,
}
candidates, err := s.manager.Start(context.Background(), r)
if id, ok := s.ensureRequest(dl); ok {
dl.RequestID = id
}
candidates, err := s.manager.Start(context.Background(), dl)
if err != nil {
return StartResult{}, err
}
result := StartResult{
RequestID: r.ID,
DownloadID: dl.ID,
Candidates: candidates,
AutoPicked: AutoPickable(r, candidates),
AutoPicked: s.manager.AutoPickable(dl, candidates),
}
s.emit(events.DownloadsChanged)
@@ -318,10 +341,51 @@ func (s *Service) Start(req SearchRequest) (StartResult, error) {
return result, nil
}
// ensureRequest resolves or creates the durable Request an anchored
// manual download should be attached to. Free-text downloads (no
// MBID) have nothing stable to attach to and are left alone.
//
// AddRequest already treats "asking twice" as one request and never
// resets backoff or un-pauses a paused request on conflict, so a
// manual download on something already paused still runs its one
// interactive attempt now without disturbing the request's state.
func (s *Service) ensureRequest(d Download) (int64, bool) {
var (
entity Entity
mbid string
)
switch {
case d.ReleaseMBID != "":
entity, mbid = EntityRelease, d.ReleaseMBID
case d.ReleaseGroupMBID != "":
entity, mbid = EntityReleaseGroup, d.ReleaseGroupMBID
case d.RecordingMBID != "":
entity, mbid = EntityRecording, d.RecordingMBID
default:
return 0, false
}
id, err := s.store.AddRequest(context.Background(), Request{
MBID: mbid,
Entity: entity,
LibraryID: d.LibraryID,
Artist: d.Artist,
Title: d.Album,
})
if err != nil {
s.logger.Warn("could not attach request to manual download", "error", err)
return 0, false
}
return id, true
}
// Pick starts the transfer for the candidate the user chose.
func (s *Service) Pick(requestID, candidateID string) error {
func (s *Service) Pick(downloadID, candidateID string) error {
if err := s.manager.Pick(
context.Background(), requestID, candidateID,
context.Background(), downloadID, candidateID,
); err != nil {
return err
}
@@ -331,9 +395,9 @@ func (s *Service) Pick(requestID, candidateID string) error {
return nil
}
// Cancel aborts a live request.
func (s *Service) Cancel(requestID string) error {
if err := s.manager.Cancel(context.Background(), requestID); err != nil {
// Cancel aborts a live download.
func (s *Service) Cancel(downloadID string) error {
if err := s.manager.Cancel(context.Background(), downloadID); err != nil {
return err
}
@@ -342,23 +406,28 @@ func (s *Service) Cancel(requestID string) error {
return nil
}
// Candidates returns the ranked candidates of a live request, so the
// Candidates returns the ranked candidates of a live download, so the
// picker can be reopened without searching again.
func (s *Service) Candidates(requestID string) []Candidate {
return s.manager.Candidates(requestID)
func (s *Service) Candidates(downloadID string) []Candidate {
return s.manager.Candidates(downloadID)
}
// RequestView is one row of the downloads list.
type RequestView struct {
Request
// DownloadView is one row of the downloads list.
//
// would stop reading as "one row of the Downloads list" the moment this
// package also has a Requests list — see RequestInput/Request nearby.
//
//nolint:revive // stutters as download.DownloadView, but a bare "View"
type DownloadView struct {
Download
State State `json:"state"`
Error string `json:"error,omitempty"`
Items []Item `json:"items"`
State State `json:"state"`
Error string `json:"error,omitempty"`
Items []DownloadItem `json:"items"`
}
// ListRequests returns recent download requests, newest first.
func (s *Service) ListRequests(limit int) ([]RequestView, error) {
// ListDownloads returns recent downloads, newest first.
func (s *Service) ListDownloads(limit int) ([]DownloadView, error) {
const defaultLimit = 50
if limit <= 0 {
@@ -367,36 +436,36 @@ func (s *Service) ListRequests(limit int) ([]RequestView, error) {
ctx := context.Background()
requests, err := s.store.ListRequests(ctx, limit)
downloads, err := s.store.ListDownloads(ctx, limit)
if err != nil {
return nil, err
}
out := make([]RequestView, 0, len(requests))
out := make([]DownloadView, 0, len(downloads))
for _, r := range requests {
state, errText, err := s.store.GetRequestState(ctx, r.ID)
for _, d := range downloads {
state, errText, err := s.store.GetDownloadState(ctx, d.ID)
if err != nil {
return nil, err
}
items, err := s.store.ListItemsForRequest(ctx, r.ID)
items, err := s.store.ListItemsForDownload(ctx, d.ID)
if err != nil {
return nil, err
}
out = append(out, RequestView{
Request: r,
State: state,
Error: errText,
Items: items,
out = append(out, DownloadView{
Download: d,
State: state,
Error: errText,
Items: items,
})
}
return out, nil
}
// ClearFinished removes terminal requests from the list.
// ClearFinished removes terminal downloads from the list.
func (s *Service) ClearFinished() error {
if err := s.store.ClearFinished(context.Background()); err != nil {
return err
@@ -408,19 +477,20 @@ func (s *Service) ClearFinished() error {
}
// ---------------------------------------------------------------------------
// Wanted list
// Request list
// ---------------------------------------------------------------------------
// SetReconciler wires the wanted-list loop. Optional: without it the
// wanted list still stores and lists wants, it just never acts on them.
// SetReconciler wires the request-list loop. Optional: without it the
// request list still stores and lists requests, it just never acts on
// them.
func (s *Service) SetReconciler(r *Reconciler) {
s.reconciler = r
}
// WantRequest is what the frontend submits to want something. It is
// one MBID and the type of thing it names, because that is genuinely
// all a want is.
type WantRequest struct {
// RequestInput is what the frontend submits to request something. It
// is one MBID and the type of thing it names, because that is
// genuinely all a durable request is.
type RequestInput struct {
MBID string `json:"mbid"`
Entity string `json:"entity"`
LibraryID int64 `json:"libraryId"`
@@ -431,15 +501,15 @@ type WantRequest struct {
Artist string `json:"artist"`
Title string `json:"title"`
// Scope and Secondary apply to artist wants.
// Scope and Secondary apply to artist requests.
Scope string `json:"scope"`
Secondary bool `json:"secondary"`
}
// AddWant puts something on the wanted list and asks for a reconcile
// pass, so the user sees something happen rather than waiting six hours
// for the next scheduled one.
func (s *Service) AddWant(req WantRequest) (int64, error) {
// AddRequest puts something on the request list and asks for a
// reconcile pass, so the user sees something happen rather than
// waiting six hours for the next scheduled one.
func (s *Service) AddRequest(req RequestInput) (int64, error) {
if req.LibraryID <= 0 {
return 0, ErrNoLibrary
}
@@ -449,12 +519,12 @@ func (s *Service) AddWant(req WantRequest) (int64, error) {
return 0, fmt.Errorf("%w: entity %q", ErrUnsupported, req.Entity)
}
scope := WantScope(req.Scope)
scope := RequestScope(req.Scope)
if scope != ScopeAll {
scope = ScopeFuture
}
id, err := s.store.AddWant(context.Background(), Want{
id, err := s.store.AddRequest(context.Background(), Request{
MBID: req.MBID,
Entity: entity,
LibraryID: req.LibraryID,
@@ -467,7 +537,7 @@ func (s *Service) AddWant(req WantRequest) (int64, error) {
return 0, err
}
s.emit(events.WantedListChanged)
s.emit(events.RequestsChanged)
if s.reconciler != nil {
s.reconciler.Trigger()
@@ -476,73 +546,73 @@ func (s *Service) AddWant(req WantRequest) (int64, error) {
return id, nil
}
// ListWants returns the whole wanted list.
func (s *Service) ListWants() ([]Want, error) {
return s.store.ListWants(context.Background())
// ListRequests returns the whole durable request list.
func (s *Service) ListRequests() ([]Request, error) {
return s.store.ListRequests(context.Background())
}
// IsWanted answers the Explore pages' question — should this album show
// "want" or "wanted?" — without making them load the whole list.
func (s *Service) IsWanted(mbid string, libraryID int64) (bool, error) {
_, found, err := s.store.FindWant(context.Background(), mbid, libraryID)
// IsRequested answers the Explore pages' question — should this album
// show "want" or "wanted?" — without making them load the whole list.
func (s *Service) IsRequested(mbid string, libraryID int64) (bool, error) {
_, found, err := s.store.FindRequest(context.Background(), mbid, libraryID)
return found, err
}
// RemoveWant takes something off the list. Removing an artist takes
// RemoveRequest takes something off the list. Removing an artist takes
// its derived albums with it, by cascade; an album the user pinned
// themselves has no parent and survives.
func (s *Service) RemoveWant(id int64) error {
func (s *Service) RemoveRequest(id int64) error {
ctx := context.Background()
// Tell any external list first, while the row is still readable.
s.withdrawExternal(ctx, id)
if err := s.store.DeleteWant(ctx, id); err != nil {
if err := s.store.DeleteRequest(ctx, id); err != nil {
return err
}
s.emit(events.WantedListChanged)
s.emit(events.RequestsChanged)
return nil
}
// PauseWant stops attempts without forgetting the want.
func (s *Service) PauseWant(id int64, paused bool) error {
state := WantStateWanted
// PauseRequest stops attempts without forgetting the request.
func (s *Service) PauseRequest(id int64, paused bool) error {
state := RequestStateWanted
if paused {
state = WantStatePaused
state = RequestStatePaused
}
if err := s.store.SetWantState(
if err := s.store.SetRequestState(
context.Background(), id, state, "",
); err != nil {
return err
}
s.emit(events.WantedListChanged)
s.emit(events.RequestsChanged)
return nil
}
// ClearSatisfiedWants drops everything already owned.
func (s *Service) ClearSatisfiedWants() error {
if err := s.store.ClearSatisfiedWants(context.Background()); err != nil {
// ClearSatisfiedRequests drops everything already owned.
func (s *Service) ClearSatisfiedRequests() error {
if err := s.store.ClearSatisfiedRequests(context.Background()); err != nil {
return err
}
s.emit(events.WantedListChanged)
s.emit(events.RequestsChanged)
return nil
}
// ReconcileWanted runs a pass now and reports what it did. This backs
// the "check now" button, so it runs synchronously: the user pressed it
// and is waiting for an answer.
func (s *Service) ReconcileWanted() (Summary, error) {
// ReconcileRequests runs a pass now and reports what it did. This
// backs the "check now" button, so it runs synchronously: the user
// pressed it and is waiting for an answer.
func (s *Service) ReconcileRequests() (Summary, error) {
if s.reconciler == nil {
return Summary{}, fmt.Errorf(
"%w: the wanted list is not running", ErrUnsupported,
"%w: the request list is not running", ErrUnsupported,
)
}
@@ -551,20 +621,20 @@ func (s *Service) ReconcileWanted() (Summary, error) {
return summary, err
}
s.emit(events.WantedListChanged)
s.emit(events.RequestsChanged)
return summary, nil
}
// ImportExternalWants adopts a provider's own list — "import the
// ImportExternalRequests adopts a provider's own list — "import the
// artists Lidarr is already monitoring".
func (s *Service) ImportExternalWants(
func (s *Service) ImportExternalRequests(
providerID int64,
libraryID int64,
) (int, error) {
if s.reconciler == nil {
return 0, fmt.Errorf(
"%w: the wanted list is not running", ErrUnsupported,
"%w: the request list is not running", ErrUnsupported,
)
}
@@ -575,22 +645,22 @@ func (s *Service) ImportExternalWants(
return 0, err
}
s.emit(events.WantedListChanged)
s.emit(events.RequestsChanged)
return n, nil
}
// withdrawExternal best-effort unmonitors a want in the external lists
// it was pushed to. Failures are logged and ignored: the user asked to
// remove it from *this* list, and an unreachable Lidarr is not a reason
// to refuse.
// withdrawExternal best-effort unmonitors a request in the external
// lists it was pushed to. Failures are logged and ignored: the user
// asked to remove it from *this* list, and an unreachable Lidarr is not
// a reason to refuse.
func (s *Service) withdrawExternal(ctx context.Context, id int64) {
w, err := s.store.GetWant(ctx, id)
if err != nil || len(w.ExternalIDs) == 0 {
r, err := s.store.GetRequest(ctx, id)
if err != nil || len(r.ExternalIDs) == 0 {
return
}
for key, externalID := range w.ExternalIDs {
for key, externalID := range r.ExternalIDs {
providerID, err := strconv.ParseInt(key, 10, 64)
if err != nil {
continue
@@ -601,10 +671,10 @@ func (s *Service) withdrawExternal(ctx context.Context, id int64) {
continue
}
if err := l.RemoveWant(ctx, externalID); err != nil {
if err := l.RemoveRequest(ctx, externalID); err != nil {
s.logger.Debug(
"could not withdraw want from external list",
"want", id,
"could not withdraw request from external list",
"request", id,
"provider", providerID,
"error", err,
)