package download import ( "context" "errors" "fmt" "log/slog" "strconv" "github.com/wailsapp/wails/v3/pkg/application" "yellowjacket/backend/events" ) // 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. // Its methods are bound into Wails and called from TypeScript, so // signatures use plain types and return errors the UI can render. type Service struct { logger *slog.Logger manager *Manager store *Store secrets SecretStore // reconciler works the wanted list. Optional; nil means wants are // stored but never acted on. reconciler *Reconciler ctx context.Context } // NewService builds the bound service. func NewService( logger *slog.Logger, manager *Manager, store *Store, secrets SecretStore, ) *Service { return &Service{ logger: logger, manager: manager, store: store, secrets: secrets, } } // ServiceStartup is v3's service lifecycle hook: it runs once the // runtime exists, and ctx is cancelled when the app shuts down. It // replaces v2's SetContext, which had to be called by hand from // OnStartup and was exported, so it was also bound to the frontend. func (s *Service) ServiceStartup( ctx context.Context, _ application.ServiceOptions, ) error { s.ctx = ctx return nil } // emit publishes an event, tolerating a service that has no runtime // context yet. func (s *Service) emit(name string, data ...any) { events.Emit(s.ctx, name, data...) } // --------------------------------------------------------------------------- // Provider configuration // --------------------------------------------------------------------------- // ProviderKinds returns every provider type that can be added, with the // settings each one needs. The settings page renders its forms from // this, so a new adapter needs no frontend change. func (s *Service) ProviderKinds() []Descriptor { return Descriptors() } // ListProviders returns the user's configured download clients. func (s *Service) ListProviders() ([]Config, error) { cfgs, err := s.store.ListProviders(context.Background()) if err != nil { return nil, err } for i := range cfgs { cfgs[i].SetSecrets = s.setSecretFlags(cfgs[i]) } return cfgs, nil } // setSecretFlags reports, for each secret field the provider's kind // declares, whether a value is already stored — so the settings form // can distinguish an unset secret from one it simply isn't shown. func (s *Service) setSecretFlags(cfg Config) map[string]bool { desc, ok := DescriptorFor(cfg.Kind) if !ok { return nil } flags := map[string]bool{} for _, field := range desc.Fields { if !field.Secret { continue } v, err := s.secrets.Get(cfg.ID, field.Key) flags[field.Key] = err == nil && v != "" } return flags } // AddProvider creates a provider and stores any secret settings // separately. Secrets arrive in the same map as ordinary settings // because that is what the form submits; they are split out here and // never written to the provider row. func (s *Service) AddProvider( kind string, name string, settings map[string]string, ) (int64, error) { desc, ok := DescriptorFor(Kind(kind)) if !ok { return 0, fmt.Errorf("%w: %s", ErrUnknownKind, kind) } plain, secret := splitSecrets(desc, settings) id, err := s.store.CreateProvider(context.Background(), Config{ Kind: Kind(kind), Name: name, Enabled: true, Priority: 50, Settings: plain, }) if err != nil { return 0, err } for k, v := range secret { if err := s.secrets.Set(id, k, v); err != nil { return 0, err } } if err := s.manager.Reload(context.Background()); err != nil { return 0, err } s.emit(events.DownloadProvidersChanged) return id, nil } // UpdateProvider saves changes to a provider. A secret field left // blank keeps its stored value rather than clearing it — the form does // not echo secrets back, so an empty box means "unchanged", not // "delete". func (s *Service) UpdateProvider( id int64, name string, enabled bool, priority int, settings map[string]string, ) error { ctx := context.Background() existing, err := s.store.GetProvider(ctx, id) if err != nil { return err } desc, ok := DescriptorFor(existing.Kind) if !ok { return fmt.Errorf("%w: %s", ErrUnknownKind, existing.Kind) } plain, secret := splitSecrets(desc, settings) if err := s.store.UpdateProvider(ctx, Config{ ID: id, Kind: existing.Kind, Name: name, Enabled: enabled, Priority: priority, Settings: plain, }); err != nil { return err } for k, v := range secret { if v == "" { continue } if err := s.secrets.Set(id, k, v); err != nil { return err } } if err := s.manager.Reload(ctx); err != nil { return err } s.emit(events.DownloadProvidersChanged) return nil } // DeleteProvider removes a provider and its credentials. func (s *Service) DeleteProvider(id int64) error { ctx := context.Background() if err := s.store.DeleteProvider(ctx, id); err != nil { return err } if err := s.secrets.DeleteProvider(id); err != nil { s.logger.Warn( "could not delete provider secrets", "provider", id, "error", err, ) } if err := s.manager.Reload(ctx); err != nil { return err } s.emit(events.DownloadProvidersChanged) return nil } // TestProvider backs the "test connection" button. It builds the // provider from its stored config and asks it to check itself, so the // result reflects exactly what a real search would use. func (s *Service) TestProvider(id int64) error { ctx := context.Background() cfg, err := s.store.GetProvider(ctx, id) if err != nil { return err } p, err := New(cfg, lookupFor(s.secrets, id), s.logger) if err != nil { return err } defer func() { _ = p.Close() }() if err := p.Check(ctx); err != nil { return fmt.Errorf("%s: %w", cfg.Name, err) } 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) } // --------------------------------------------------------------------------- // Downloads // --------------------------------------------------------------------------- // SearchRequest is what the frontend submits to start a download. type SearchRequest struct { LibraryID int64 `json:"libraryId"` ReleaseMBID string `json:"releaseMbid"` ReleaseGroupMBID string `json:"releaseGroupMbid"` Artist string `json:"artist"` Album string `json:"album"` Query string `json:"query"` Expected []ExpectedTrack `json:"expected"` } // StartResult is what the picker needs after a search. type StartResult struct { DownloadID string `json:"downloadId"` Candidates []Candidate `json:"candidates"` // AutoPicked reports that the pipeline already chose and is // downloading, so the picker should show progress rather than a // list of choices. AutoPicked bool `json:"autoPicked"` } // 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 } dl := Download{ ID: newID(), LibraryID: req.LibraryID, ReleaseMBID: req.ReleaseMBID, ReleaseGroupMBID: req.ReleaseGroupMBID, Artist: req.Artist, Album: req.Album, Query: req.Query, Expected: req.Expected, } 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{ DownloadID: dl.ID, Candidates: candidates, AutoPicked: s.manager.AutoPickable(dl, candidates), } s.emit(events.DownloadsChanged) 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(downloadID, candidateID string) error { if err := s.manager.Pick( context.Background(), downloadID, candidateID, ); err != nil { return err } s.emit(events.DownloadsChanged) return 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 } s.emit(events.DownloadsChanged) return nil } // Candidates returns the ranked candidates of a live download, so the // picker can be reopened without searching again. func (s *Service) Candidates(downloadID string) []Candidate { return s.manager.Candidates(downloadID) } // 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 []DownloadItem `json:"items"` } // ListDownloads returns recent downloads, newest first. func (s *Service) ListDownloads(limit int) ([]DownloadView, error) { const defaultLimit = 50 if limit <= 0 { limit = defaultLimit } ctx := context.Background() downloads, err := s.store.ListDownloads(ctx, limit) if err != nil { return nil, err } out := make([]DownloadView, 0, len(downloads)) for _, d := range downloads { state, errText, err := s.store.GetDownloadState(ctx, d.ID) if err != nil { return nil, err } items, err := s.store.ListItemsForDownload(ctx, d.ID) if err != nil { return nil, err } out = append(out, DownloadView{ Download: d, State: state, Error: errText, Items: items, }) } return out, nil } // ClearFinished removes terminal downloads from the list. func (s *Service) ClearFinished() error { if err := s.store.ClearFinished(context.Background()); err != nil { return err } s.emit(events.DownloadsChanged) return nil } // --------------------------------------------------------------------------- // Request list // --------------------------------------------------------------------------- // SetReconciler wires the request-list loop. Optional: without it the // request list still stores and lists requests, it just never acts on // them. // //wails:ignore // internal wiring, not part of the app's IPC surface. func (s *Service) SetReconciler(r *Reconciler) { s.reconciler = r } // 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"` // Artist and Title are display text only, and optional: the // reconciler fills them in from the catalog when the caller has // nothing but an MBID. Artist string `json:"artist"` Title string `json:"title"` // Scope and Secondary apply to artist requests. Scope string `json:"scope"` Secondary bool `json:"secondary"` } // 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 } entity := Entity(req.Entity) if !entity.Valid() { return 0, fmt.Errorf("%w: entity %q", ErrUnsupported, req.Entity) } scope := RequestScope(req.Scope) if scope != ScopeAll { scope = ScopeFuture } id, err := s.store.AddRequest(context.Background(), Request{ MBID: req.MBID, Entity: entity, LibraryID: req.LibraryID, Artist: req.Artist, Title: req.Title, Scope: scope, Secondary: req.Secondary, }) if err != nil { return 0, err } s.emit(events.RequestsChanged) if s.reconciler != nil { s.reconciler.Trigger() } return id, nil } // ListRequests returns the whole durable request list. func (s *Service) ListRequests() ([]Request, error) { return s.store.ListRequests(context.Background()) } // 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 } // 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) 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.DeleteRequest(ctx, id); err != nil { return err } s.emit(events.RequestsChanged) return nil } // PauseRequest stops attempts without forgetting the request. func (s *Service) PauseRequest(id int64, paused bool) error { state := RequestStateWanted if paused { state = RequestStatePaused } if err := s.store.SetRequestState( context.Background(), id, state, "", ); err != nil { return err } s.emit(events.RequestsChanged) return 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.RequestsChanged) return nil } // 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 request list is not running", ErrUnsupported, ) } summary, err := s.reconciler.RunNow(context.Background()) if err != nil { return summary, err } s.emit(events.RequestsChanged) return summary, nil } // ImportExternalRequests adopts a provider's own list — "import the // artists Lidarr is already monitoring". func (s *Service) ImportExternalRequests( providerID int64, libraryID int64, ) (int, error) { if s.reconciler == nil { return 0, fmt.Errorf( "%w: the request list is not running", ErrUnsupported, ) } n, err := s.reconciler.ImportExternal( context.Background(), providerID, libraryID, ) if err != nil { return 0, err } s.emit(events.RequestsChanged) return n, nil } // 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) { r, err := s.store.GetRequest(ctx, id) if err != nil || len(r.ExternalIDs) == 0 { return } for key, externalID := range r.ExternalIDs { providerID, err := strconv.ParseInt(key, 10, 64) if err != nil { continue } l, ok := s.manager.listers()[providerID] if !ok { continue } if err := l.RemoveRequest(ctx, externalID); err != nil { s.logger.Debug( "could not withdraw request from external list", "request", id, "provider", providerID, "error", err, ) } } } // splitSecrets partitions a submitted settings map into plain values, // which go in the provider row, and secret values, which go in the // secret store. The split is driven by the descriptor so a provider // declaring a field secret is enough to keep it out of the database. func splitSecrets( desc Descriptor, settings map[string]string, ) (plain, secret map[string]string) { plain = make(map[string]string, len(settings)) secret = make(map[string]string) secretKeys := make(map[string]bool, len(desc.Fields)) for _, f := range desc.Fields { if f.Secret { secretKeys[f.Key] = true } } for k, v := range settings { if secretKeys[k] { secret[k] = v continue } plain[k] = v } return plain, secret }