Autotag: detect "junk drawer" folders with no artist/album consensus and split them into synthetic per-cluster groups instead of forcing one match on an unrelated pile of tracks; repair tagging_items rows left behind by a prior scan orphan-cleanup gap. Explore: fix an exact artist-name search being drowned out by its own catalog entries in intent-prior scoring, and prune stale in_library bookkeeping left behind when a referenced library row is deleted. Download: fix a multi-library regression where every import failed with "no library root configured" — the importer resolved the library root from a legacy single-library config field that nothing populates in the current multi-library model. It now resolves the destination library per-request from the request's own library_id. Also widen the Soulseek search window (12s -> 20s), measured against real request history to be missing available peers on live queries. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
646 lines
15 KiB
Go
646 lines
15 KiB
Go
package download
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strconv"
|
|
|
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
|
|
|
"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.
|
|
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,
|
|
}
|
|
}
|
|
|
|
// SetContext injects the Wails runtime context for event emission.
|
|
func (s *Service) SetContext(ctx context.Context) {
|
|
s.ctx = ctx
|
|
}
|
|
|
|
// emit publishes an event, tolerating a service that has no runtime
|
|
// context yet. Emitting on a non-runtime context is fatal in Wails, so
|
|
// the nil check is load-bearing rather than defensive.
|
|
func (s *Service) emit(name string, data ...any) {
|
|
if s.ctx == nil {
|
|
return
|
|
}
|
|
|
|
runtime.EventsEmit(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
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Requests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// 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 {
|
|
RequestID string `json:"requestId"`
|
|
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"`
|
|
}
|
|
|
|
// 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) {
|
|
if req.LibraryID <= 0 {
|
|
return StartResult{}, ErrNoLibrary
|
|
}
|
|
|
|
r := Request{
|
|
ID: newID(),
|
|
LibraryID: req.LibraryID,
|
|
ReleaseMBID: req.ReleaseMBID,
|
|
ReleaseGroupMBID: req.ReleaseGroupMBID,
|
|
Artist: req.Artist,
|
|
Album: req.Album,
|
|
Query: req.Query,
|
|
Expected: req.Expected,
|
|
}
|
|
|
|
candidates, err := s.manager.Start(context.Background(), r)
|
|
if err != nil {
|
|
return StartResult{}, err
|
|
}
|
|
|
|
result := StartResult{
|
|
RequestID: r.ID,
|
|
Candidates: candidates,
|
|
AutoPicked: AutoPickable(r, candidates),
|
|
}
|
|
|
|
s.emit(events.DownloadsChanged)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// Pick starts the transfer for the candidate the user chose.
|
|
func (s *Service) Pick(requestID, candidateID string) error {
|
|
if err := s.manager.Pick(
|
|
context.Background(), requestID, candidateID,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.emit(events.DownloadsChanged)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Cancel aborts a live request.
|
|
func (s *Service) Cancel(requestID string) error {
|
|
if err := s.manager.Cancel(context.Background(), requestID); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.emit(events.DownloadsChanged)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Candidates returns the ranked candidates of a live request, so the
|
|
// picker can be reopened without searching again.
|
|
func (s *Service) Candidates(requestID string) []Candidate {
|
|
return s.manager.Candidates(requestID)
|
|
}
|
|
|
|
// RequestView is one row of the downloads list.
|
|
type RequestView struct {
|
|
Request
|
|
|
|
State State `json:"state"`
|
|
Error string `json:"error,omitempty"`
|
|
Items []Item `json:"items"`
|
|
}
|
|
|
|
// ListRequests returns recent download requests, newest first.
|
|
func (s *Service) ListRequests(limit int) ([]RequestView, error) {
|
|
const defaultLimit = 50
|
|
|
|
if limit <= 0 {
|
|
limit = defaultLimit
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
requests, err := s.store.ListRequests(ctx, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]RequestView, 0, len(requests))
|
|
|
|
for _, r := range requests {
|
|
state, errText, err := s.store.GetRequestState(ctx, r.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
items, err := s.store.ListItemsForRequest(ctx, r.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out = append(out, RequestView{
|
|
Request: r,
|
|
State: state,
|
|
Error: errText,
|
|
Items: items,
|
|
})
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// ClearFinished removes terminal requests 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
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wanted list
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// SetReconciler wires the wanted-list loop. Optional: without it the
|
|
// wanted list still stores and lists wants, 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 {
|
|
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 wants.
|
|
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) {
|
|
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 := WantScope(req.Scope)
|
|
if scope != ScopeAll {
|
|
scope = ScopeFuture
|
|
}
|
|
|
|
id, err := s.store.AddWant(context.Background(), Want{
|
|
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.WantedListChanged)
|
|
|
|
if s.reconciler != nil {
|
|
s.reconciler.Trigger()
|
|
}
|
|
|
|
return id, nil
|
|
}
|
|
|
|
// ListWants returns the whole wanted list.
|
|
func (s *Service) ListWants() ([]Want, error) {
|
|
return s.store.ListWants(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)
|
|
|
|
return found, err
|
|
}
|
|
|
|
// RemoveWant 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 {
|
|
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 {
|
|
return err
|
|
}
|
|
|
|
s.emit(events.WantedListChanged)
|
|
|
|
return nil
|
|
}
|
|
|
|
// PauseWant stops attempts without forgetting the want.
|
|
func (s *Service) PauseWant(id int64, paused bool) error {
|
|
state := WantStateWanted
|
|
if paused {
|
|
state = WantStatePaused
|
|
}
|
|
|
|
if err := s.store.SetWantState(
|
|
context.Background(), id, state, "",
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.emit(events.WantedListChanged)
|
|
|
|
return nil
|
|
}
|
|
|
|
// ClearSatisfiedWants drops everything already owned.
|
|
func (s *Service) ClearSatisfiedWants() error {
|
|
if err := s.store.ClearSatisfiedWants(context.Background()); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.emit(events.WantedListChanged)
|
|
|
|
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) {
|
|
if s.reconciler == nil {
|
|
return Summary{}, fmt.Errorf(
|
|
"%w: the wanted list is not running", ErrUnsupported,
|
|
)
|
|
}
|
|
|
|
summary, err := s.reconciler.RunOnce(context.Background())
|
|
if err != nil {
|
|
return summary, err
|
|
}
|
|
|
|
s.emit(events.WantedListChanged)
|
|
|
|
return summary, nil
|
|
}
|
|
|
|
// ImportExternalWants adopts a provider's own list — "import the
|
|
// artists Lidarr is already monitoring".
|
|
func (s *Service) ImportExternalWants(
|
|
providerID int64,
|
|
libraryID int64,
|
|
) (int, error) {
|
|
if s.reconciler == nil {
|
|
return 0, fmt.Errorf(
|
|
"%w: the wanted list is not running", ErrUnsupported,
|
|
)
|
|
}
|
|
|
|
n, err := s.reconciler.ImportExternal(
|
|
context.Background(), providerID, libraryID,
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
s.emit(events.WantedListChanged)
|
|
|
|
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.
|
|
func (s *Service) withdrawExternal(ctx context.Context, id int64) {
|
|
w, err := s.store.GetWant(ctx, id)
|
|
if err != nil || len(w.ExternalIDs) == 0 {
|
|
return
|
|
}
|
|
|
|
for key, externalID := range w.ExternalIDs {
|
|
providerID, err := strconv.ParseInt(key, 10, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
l, ok := s.manager.listers()[providerID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
if err := l.RemoveWant(ctx, externalID); err != nil {
|
|
s.logger.Debug(
|
|
"could not withdraw want from external list",
|
|
"want", 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
|
|
}
|