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
This commit is contained in:
+166
-96
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user