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
+10 -10
View File
@@ -84,17 +84,17 @@ func TestPerProviderCapSerializesTransfers(t *testing.T) {
// Three requests against the same one-at-a-time provider.
for i := range 3 {
req := fourTrackRequest()
req.ID = "req-" + string(rune('a'+i))
dl := fourTrackDownload()
dl.ID = "dl-" + string(rune('a'+i))
if err := f.store.CreateRequest(ctx, req); err != nil {
t.Fatalf("CreateRequest: %v", err)
if err := f.store.CreateDownload(ctx, dl); err != nil {
t.Fatalf("CreateDownload: %v", err)
}
candidate := slow.Candidates[0]
candidate.ProviderID = 1
go f.manager.grab(ctx, req, candidate, nil)
go f.manager.grab(ctx, dl, candidate, nil)
}
// Give all three a chance to reach the transport, then check how
@@ -139,17 +139,17 @@ func TestPerProviderCapAllowsParallelWhereSafe(t *testing.T) {
ctx := context.Background()
for i := range 3 {
req := fourTrackRequest()
req.ID = "req-" + string(rune('a'+i))
dl := fourTrackDownload()
dl.ID = "dl-" + string(rune('a'+i))
if err := f.store.CreateRequest(ctx, req); err != nil {
t.Fatalf("CreateRequest: %v", err)
if err := f.store.CreateDownload(ctx, dl); err != nil {
t.Fatalf("CreateDownload: %v", err)
}
candidate := fast.Candidates[0]
candidate.ProviderID = 1
go f.manager.grab(ctx, req, candidate, nil)
go f.manager.grab(ctx, dl, candidate, nil)
}
waitFor(
+28
View File
@@ -33,6 +33,34 @@ type UserConfig struct {
// for. A large list should be worked through steadily rather than
// in one burst that every provider sees as a flood.
WantedBatch int `toml:"WantedBatch"`
// MinFileSizeMB, MaxFileSizeMB and PreferredFileSizeMB bound and
// nudge what auto-pick (interactive or via the request list) may
// grab without asking. Zero on any of them is permissive: see
// AutoDownloadPrefs.
MinFileSizeMB int `toml:"MinFileSizeMB"`
MaxFileSizeMB int `toml:"MaxFileSizeMB"`
PreferredFileSizeMB int `toml:"PreferredFileSizeMB"`
// AllowedFormats restricts auto-pick to these formats. Empty means
// no restriction. Values are Format strings ("flac", "mp3", ...).
AllowedFormats []string `toml:"AllowedFormats"`
}
// AutoDownloadPrefs converts the persisted guardrail fields to the
// runtime type Manager and the ranker consume.
func (c *UserConfig) AutoDownloadPrefs() AutoDownloadPrefs {
formats := make([]Format, 0, len(c.AllowedFormats))
for _, f := range c.AllowedFormats {
formats = append(formats, Format(f))
}
return AutoDownloadPrefs{
MinSizeMB: c.MinFileSizeMB,
MaxSizeMB: c.MaxFileSizeMB,
PreferredSizeMB: c.PreferredFileSizeMB,
AllowedFormats: formats,
}
}
// ApplyDefaults fills unset fields.
+2 -2
View File
@@ -92,7 +92,7 @@ func (f *FakeProvider) Close() error {
// Search returns the configured candidates.
func (f *FakeProvider) Search(
ctx context.Context,
_ Request,
_ Download,
) ([]Candidate, error) {
f.mu.Lock()
f.SearchCalls++
@@ -208,7 +208,7 @@ 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,
_ Request,
_ Download,
) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
+21 -21
View File
@@ -125,7 +125,7 @@ type ImportResult struct {
// retry or inspect it; only a fully successful import releases staging.
func (i *Importer) Import(
ctx context.Context,
req Request,
dl Download,
result Result,
opts ImportOptions,
) (ImportResult, error) {
@@ -139,13 +139,13 @@ func (i *Importer) Import(
return ImportResult{}, ErrNoAudio
}
if err := checkCompleteness(len(audio), req); err != nil {
if err := checkCompleteness(len(audio), dl); err != nil {
return ImportResult{}, err
}
// Align staged files to the expected tracklist so tags and
// filenames reflect the release, not the uploader's naming.
plan := i.planFiles(audio, req)
plan := i.planFiles(audio, dl)
out := ImportResult{
Paths: make([]string, 0, len(plan)),
@@ -158,7 +158,7 @@ func (i *Importer) Import(
}
if opts.WriteTags {
if err := i.tagFile(p, req); err != nil {
if err := i.tagFile(p, dl); err != nil {
// A file that cannot be tagged is still worth importing
// — the scanner will read whatever tags it has, and the
// autotag queue can pick it up later. Losing the whole
@@ -173,7 +173,7 @@ func (i *Importer) Import(
}
}
dest, err := i.destinationFor(p, req, opts)
dest, err := i.destinationFor(p, dl, opts)
if err != nil {
return out, err
}
@@ -199,7 +199,7 @@ type plannedFile struct {
}
// planFiles aligns staged files to the expected tracklist.
func (i *Importer) planFiles(audio []string, req Request) []plannedFile {
func (i *Importer) planFiles(audio []string, dl Download) []plannedFile {
files := make([]CandidateFile, 0, len(audio))
for _, a := range audio {
@@ -211,10 +211,10 @@ func (i *Importer) planFiles(audio []string, req Request) []plannedFile {
})
}
matched, _ := matchFiles(files, req.Expected)
matched, _ := matchFiles(files, dl.Expected)
byPosition := make(map[int]ExpectedTrack, len(req.Expected))
for _, e := range req.Expected {
byPosition := make(map[int]ExpectedTrack, len(dl.Expected))
for _, e := range dl.Expected {
byPosition[e.Position] = e
}
@@ -253,14 +253,14 @@ func (i *Importer) planFiles(audio []string, req Request) []plannedFile {
}
// tagFile writes the release's metadata onto a staged file.
func (i *Importer) tagFile(p plannedFile, req Request) error {
func (i *Importer) tagFile(p plannedFile, dl Download) error {
if i.tags == nil || !p.Matched {
return nil
}
changes := tagwriter.TagChanges{
tagwriter.FieldAlbum: req.Album,
tagwriter.FieldAlbumArtist: req.Artist,
tagwriter.FieldAlbum: dl.Album,
tagwriter.FieldAlbumArtist: dl.Artist,
tagwriter.FieldTitle: p.Track.Title,
tagwriter.FieldTrackNumber: p.Track.Position,
}
@@ -268,7 +268,7 @@ func (i *Importer) tagFile(p plannedFile, req Request) error {
if p.Track.Artist != "" {
changes[tagwriter.FieldArtist] = p.Track.Artist
} else {
changes[tagwriter.FieldArtist] = req.Artist
changes[tagwriter.FieldArtist] = dl.Artist
}
if p.Track.DiscNumber > 0 {
@@ -285,7 +285,7 @@ func (i *Importer) tagFile(p plannedFile, req Request) error {
// destinationFor computes a file's library path from the template.
func (i *Importer) destinationFor(
p plannedFile,
req Request,
dl Download,
opts ImportOptions,
) (string, error) {
if opts.LibraryRoot == "" {
@@ -311,13 +311,13 @@ func (i *Importer) destinationFor(
artist := p.Track.Artist
if artist == "" {
artist = req.Artist
artist = dl.Artist
}
repl := strings.NewReplacer(
"{albumartist}", sanitizePathPart(fallback(req.Artist, "Unknown Artist")),
"{albumartist}", sanitizePathPart(fallback(dl.Artist, "Unknown Artist")),
"{artist}", sanitizePathPart(fallback(artist, "Unknown Artist")),
"{album}", sanitizePathPart(fallback(req.Album, "Unknown Album")),
"{album}", sanitizePathPart(fallback(dl.Album, "Unknown Album")),
"{title}", sanitizePathPart(title),
"{track}", trackToken(p.Track.Position),
"{disc}", strconv.Itoa(p.Track.DiscNumber),
@@ -443,16 +443,16 @@ func copyFile(src, dest string) error {
// checkCompleteness rejects an anchored download that is missing too
// much of its tracklist.
func checkCompleteness(got int, req Request) error {
if len(req.Expected) == 0 {
func checkCompleteness(got int, dl Download) error {
if len(dl.Expected) == 0 {
return nil
}
ratio := float64(got) / float64(len(req.Expected))
ratio := float64(got) / float64(len(dl.Expected))
if ratio < minCompleteness {
return fmt.Errorf(
"%w: got %d of %d tracks",
ErrTooIncomplete, got, len(req.Expected),
ErrTooIncomplete, got, len(dl.Expected),
)
}
+20 -20
View File
@@ -117,9 +117,9 @@ func newImportFixture(t *testing.T, names ...string) importFixture {
}
}
func fourTrackRequest() Request {
return Request{
ID: "req-1",
func fourTrackDownload() Download {
return Download{
ID: "dl-1",
LibraryID: 1,
ReleaseMBID: "mbid-1",
Artist: "Radiohead",
@@ -145,7 +145,7 @@ func TestImportPlacesAndTagsFiles(t *testing.T) {
got, err := f.importer.Import(
context.Background(),
fourTrackRequest(),
fourTrackDownload(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
@@ -184,12 +184,12 @@ func TestImportTagsBeforeMoving(t *testing.T) {
f := newImportFixture(t, "01 - Airbag.flac")
req := fourTrackRequest()
req.Expected = req.Expected[:1]
dl := fourTrackDownload()
dl.Expected = dl.Expected[:1]
if _, err := f.importer.Import(
context.Background(),
req,
dl,
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
); err != nil {
@@ -227,7 +227,7 @@ func TestImportContinuesWhenTaggingFails(t *testing.T) {
got, err := f.importer.Import(
context.Background(),
fourTrackRequest(),
fourTrackDownload(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
@@ -251,7 +251,7 @@ func TestImportRejectsTooIncomplete(t *testing.T) {
_, err := f.importer.Import(
context.Background(),
fourTrackRequest(),
fourTrackDownload(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
@@ -273,7 +273,7 @@ func TestImportRejectsNoAudio(t *testing.T) {
_, err := f.importer.Import(
context.Background(),
fourTrackRequest(),
fourTrackDownload(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
@@ -297,7 +297,7 @@ func TestImportSkipsNonAudioFiles(t *testing.T) {
got, err := f.importer.Import(
context.Background(),
fourTrackRequest(),
fourTrackDownload(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
@@ -322,8 +322,8 @@ func TestImportNeverOverwrites(t *testing.T) {
f := newImportFixture(t, "01 - Airbag.flac")
req := fourTrackRequest()
req.Expected = req.Expected[:1]
dl := fourTrackDownload()
dl.Expected = dl.Expected[:1]
existing := filepath.Join(
f.root, "Radiohead", "OK Computer", "01 Airbag.flac",
@@ -339,7 +339,7 @@ func TestImportNeverOverwrites(t *testing.T) {
got, err := f.importer.Import(
context.Background(),
req,
dl,
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
)
@@ -366,12 +366,12 @@ func TestImportCustomPathTemplate(t *testing.T) {
f := newImportFixture(t, "01 - Airbag.flac")
req := fourTrackRequest()
req.Expected = req.Expected[:1]
dl := fourTrackDownload()
dl.Expected = dl.Expected[:1]
got, err := f.importer.Import(
context.Background(),
req,
dl,
Result{Dir: f.dir, Files: f.files},
ImportOptions{
LibraryRoot: f.root,
@@ -423,12 +423,12 @@ func TestImportRequiresLibraryRoot(t *testing.T) {
f := newImportFixture(t, "01 - Airbag.flac")
req := fourTrackRequest()
req.Expected = req.Expected[:1]
dl := fourTrackDownload()
dl.Expected = dl.Expected[:1]
_, err := f.importer.Import(
context.Background(),
req,
dl,
Result{Dir: f.dir, Files: f.files},
ImportOptions{WriteTags: true},
)
+132 -102
View File
@@ -124,6 +124,10 @@ type Manager struct {
optsMu sync.RWMutex
opts ImportOptions
// prefs gates and scores what auto-pick may grab without asking.
prefsMu sync.RWMutex
prefs AutoDownloadPrefs
// providers caches built provider instances by config ID. Rebuilt
// whenever config changes, so a settings edit takes effect without
// a restart.
@@ -206,6 +210,32 @@ func (m *Manager) importOptions() ImportOptions {
return m.opts
}
// SetPreferences configures the auto-download guardrails: the size
// window and format list AutoPickable is allowed to grab without
// asking. Live-settable so a settings change takes effect immediately,
// the same way SetImportOptions does.
func (m *Manager) SetPreferences(prefs AutoDownloadPrefs) {
m.prefsMu.Lock()
defer m.prefsMu.Unlock()
m.prefs = prefs
}
// preferences returns the current auto-download guardrails.
func (m *Manager) preferences() AutoDownloadPrefs {
m.prefsMu.RLock()
defer m.prefsMu.RUnlock()
return m.prefs
}
// AutoPickable wraps the package function with this manager's current
// preferences, so callers do not need direct field access to apply the
// live guardrails.
func (m *Manager) AutoPickable(dl Download, ranked []Candidate) bool {
return AutoPickable(dl, ranked, m.preferences())
}
// Reload rebuilds every provider from stored config. Called at startup
// and after any provider settings change.
//
@@ -293,12 +323,12 @@ func (m *Manager) Sweep(ctx context.Context) {
)
}
if err := m.store.SetRequestState(
ctx, item.RequestID, StateFailed, "interrupted by restart",
if err := m.store.SetDownloadState(
ctx, item.DownloadID, StateFailed, "interrupted by restart",
); err != nil {
m.logger.Warn(
"could not fail interrupted download request",
"request", item.RequestID, "error", err,
"request", item.DownloadID, "error", err,
)
}
}
@@ -399,7 +429,7 @@ func (m *Manager) syncSemaphores(configs map[int64]Config) {
}
}
// listers returns every enabled provider that keeps a persistent wanted
// listers returns every enabled provider that keeps a persistent
// list of its own, keyed by provider ID.
func (m *Manager) listers() map[int64]Lister {
m.provMu.RLock()
@@ -434,7 +464,7 @@ func (m *Manager) priorityFor(id int64) int {
// and skipped, because partial results beat no results.
func (m *Manager) Search(
ctx context.Context,
req Request,
dl Download,
) ([]Candidate, error) {
providers := m.enabledProviders()
if len(providers) == 0 {
@@ -462,7 +492,7 @@ func (m *Manager) Search(
sctx, cancel := context.WithTimeout(ctx, searchTimeout)
defer cancel()
c, err := s.Search(sctx, req)
c, err := s.Search(sctx, dl)
results <- found{candidates: c, err: err, id: id}
}(id, s)
}
@@ -497,7 +527,7 @@ func (m *Manager) Search(
return nil, ErrNoCandidates
}
return Rank(req, all, m.priorityFor), nil
return Rank(dl, all, m.priorityFor, m.preferences()), nil
}
// Start creates a request, searches for it, and either grabs the clear
@@ -506,31 +536,31 @@ func (m *Manager) Search(
// in the background under a job.
func (m *Manager) Start(
ctx context.Context,
req Request,
dl Download,
) ([]Candidate, error) {
if req.ID == "" {
req.ID = newID()
if dl.ID == "" {
dl.ID = newID()
}
if err := m.store.CreateRequest(ctx, req); err != nil {
if err := m.store.CreateDownload(ctx, dl); err != nil {
return nil, err
}
job := m.startJob(req)
job := m.startJob(dl)
ranked, err := m.Search(ctx, req)
ranked, err := m.Search(ctx, dl)
if err != nil {
m.failRequest(ctx, job, req.ID, err)
m.failDownload(ctx, job, dl.ID, err)
return nil, err
}
m.resMu.Lock()
m.results[req.ID] = ranked
m.results[dl.ID] = ranked
m.resMu.Unlock()
if err := m.store.SetRequestState(
ctx, req.ID, StateFound, "",
if err := m.store.SetDownloadState(
ctx, dl.ID, StateFound, "",
); err != nil {
m.logger.Warn("could not record found state", "error", err)
}
@@ -541,12 +571,12 @@ func (m *Manager) Start(
))
}
if AutoPickable(req, ranked) {
if m.AutoPickable(dl, ranked) {
if job != nil {
job.Logf(jobs.LevelInfo, "Auto-selected best candidate")
}
go m.grab(context.WithoutCancel(ctx), req, ranked[0], job)
go m.grab(context.WithoutCancel(ctx), dl, ranked[0], job)
return ranked, nil
}
@@ -559,30 +589,30 @@ func (m *Manager) Start(
return ranked, nil
}
// Attempt searches on behalf of the wanted list and starts a download
// Attempt searches on behalf of the request list and starts a download
// only if there is a clear winner. It returns whether it started and,
// when it did not, a sentence the wanted list can show the user.
// when it did not, a sentence the request list can show the user.
//
// Unlike Start it persists nothing when it does not act. A want that
// Unlike Start it persists nothing when it does not act. A request that
// is retried weekly for a year would otherwise leave fifty failed
// request rows behind it, all saying the same thing the want itself
// download rows behind it, all saying the same thing the request itself
// already says — and none of them anything the user can do something
// about. Nobody is watching a reconcile pass, so the only two honest
// outcomes are "downloading it now" and "still looking".
func (m *Manager) Attempt(
ctx context.Context,
req Request,
dl Download,
) (bool, string, error) {
if req.ID == "" {
req.ID = newID()
if dl.ID == "" {
dl.ID = newID()
}
ranked, err := m.Search(ctx, req)
ranked, err := m.Search(ctx, dl)
if err != nil {
return false, "", err
}
if !AutoPickable(req, ranked) {
if !m.AutoPickable(dl, ranked) {
best := ranked[0]
return false, fmt.Sprintf(
@@ -594,28 +624,28 @@ func (m *Manager) Attempt(
), nil
}
if err := m.store.CreateRequest(ctx, req); err != nil {
if err := m.store.CreateDownload(ctx, dl); err != nil {
return false, "", err
}
m.resMu.Lock()
m.results[req.ID] = ranked
m.results[dl.ID] = ranked
m.resMu.Unlock()
if err := m.store.SetRequestState(ctx, req.ID, StateFound, ""); err != nil {
if err := m.store.SetDownloadState(ctx, dl.ID, StateFound, ""); err != nil {
m.logger.Warn("could not record found state", "error", err)
}
job := m.startJob(req)
job := m.startJob(dl)
if job != nil {
job.Logf(jobs.LevelInfo, fmt.Sprintf(
"Wanted list: auto-selected the best of %d candidates",
"Request list: auto-selected the best of %d candidates",
len(ranked),
))
}
go m.grab(context.WithoutCancel(ctx), req, ranked[0], job)
go m.grab(context.WithoutCancel(ctx), dl, ranked[0], job)
return true, "", nil
}
@@ -623,15 +653,15 @@ func (m *Manager) Attempt(
// Pick starts the transfer for a candidate the user chose.
func (m *Manager) Pick(
ctx context.Context,
requestID, candidateID string,
downloadID, candidateID string,
) error {
req, err := m.store.GetRequest(ctx, requestID)
dl, err := m.store.GetDownload(ctx, downloadID)
if err != nil {
return err
}
m.resMu.RLock()
ranked := m.results[requestID]
ranked := m.results[downloadID]
m.resMu.RUnlock()
var chosen *Candidate
@@ -648,25 +678,25 @@ func (m *Manager) Pick(
return fmt.Errorf("%w: %s", ErrCandidateGone, candidateID)
}
job := m.startJob(req)
job := m.startJob(dl)
go m.grab(context.WithoutCancel(ctx), req, *chosen, job)
go m.grab(context.WithoutCancel(ctx), dl, *chosen, job)
return nil
}
// Cancel aborts a live request.
func (m *Manager) Cancel(ctx context.Context, requestID string) error {
func (m *Manager) Cancel(ctx context.Context, downloadID string) error {
m.actMu.Lock()
cancel, ok := m.active[requestID]
cancel, ok := m.active[downloadID]
m.actMu.Unlock()
if ok {
cancel()
}
if err := m.store.SetRequestState(
ctx, requestID, StateCancelled, "",
if err := m.store.SetDownloadState(
ctx, downloadID, StateCancelled, "",
); err != nil {
return err
}
@@ -678,7 +708,7 @@ func (m *Manager) Cancel(ctx context.Context, requestID string) error {
// own goroutine and owns the job from here on.
func (m *Manager) grab(
ctx context.Context,
req Request,
dl Download,
c Candidate,
job *jobs.Handle,
) {
@@ -686,12 +716,12 @@ func (m *Manager) grab(
defer cancel()
m.actMu.Lock()
m.active[req.ID] = cancel
m.active[dl.ID] = cancel
m.actMu.Unlock()
defer func() {
m.actMu.Lock()
delete(m.active, req.ID)
delete(m.active, dl.ID)
m.actMu.Unlock()
}()
@@ -701,9 +731,9 @@ func (m *Manager) grab(
// happening inside another system, which is doing its own limiting,
// and blocking a local slot on it would be counting someone else's
// work against our budget.
plan, err := m.planTransfer(req, c)
plan, err := m.planTransfer(dl, c)
if err != nil {
m.failRequest(ctx, job, req.ID, err)
m.failDownload(ctx, job, dl.ID, err)
return
}
@@ -715,7 +745,7 @@ func (m *Manager) grab(
case provSem <- struct{}{}:
defer func() { <-provSem }()
case <-ctx.Done():
m.failRequest(ctx, job, req.ID, ctx.Err())
m.failDownload(ctx, job, dl.ID, ctx.Err())
return
}
@@ -726,15 +756,15 @@ func (m *Manager) grab(
case globalSem <- struct{}{}:
defer func() { <-globalSem }()
case <-ctx.Done():
m.failRequest(ctx, job, req.ID, ctx.Err())
m.failDownload(ctx, job, dl.ID, ctx.Err())
return
}
}
item := Item{
item := DownloadItem{
ID: newID(),
RequestID: req.ID,
DownloadID: dl.ID,
ProviderID: c.ProviderID,
Candidate: c,
State: StateQueued,
@@ -743,7 +773,7 @@ func (m *Manager) grab(
dir, err := m.staging.Reserve(item.ID)
if err != nil {
m.failRequest(ctx, job, req.ID, err)
m.failDownload(ctx, job, dl.ID, err)
return
}
@@ -751,19 +781,19 @@ func (m *Manager) grab(
item.StagingDir = dir
if err := m.store.CreateItem(ctx, item); err != nil {
m.failRequest(ctx, job, req.ID, err)
m.failDownload(ctx, job, dl.ID, err)
return
}
result, err := m.transfer(ctx, req, item, plan, job)
result, err := m.transfer(ctx, dl, item, plan, job)
if err != nil {
m.failItem(ctx, job, item, req.ID, err)
m.failItem(ctx, job, item, dl.ID, err)
return
}
m.setStates(ctx, req.ID, item.ID, StateImporting)
m.setStates(ctx, dl.ID, item.ID, StateImporting)
if job != nil {
job.SetPhase("Importing")
@@ -790,17 +820,17 @@ func (m *Manager) grab(
opts := m.importOptions()
opts.WriteTags = true
opts.LibraryRoot, err = m.library.LibraryPath(req.LibraryID)
opts.LibraryRoot, err = m.library.LibraryPath(dl.LibraryID)
if err != nil {
m.failItem(ctx, job, item, req.ID,
m.failItem(ctx, job, item, dl.ID,
fmt.Errorf("resolve library root: %w", err))
return
}
imported, err = m.importer.Import(ctx, req, result, opts)
imported, err = m.importer.Import(ctx, dl, result, opts)
if err != nil {
m.failItem(ctx, job, item, req.ID, err)
m.failItem(ctx, job, item, dl.ID, err)
return
}
@@ -812,21 +842,21 @@ func (m *Manager) grab(
m.logger.Warn("could not record imported paths", "error", err)
}
if err := m.store.SetRequestState(
ctx, req.ID, StateComplete, "",
if err := m.store.SetDownloadState(
ctx, dl.ID, StateComplete, "",
); err != nil {
m.logger.Warn("could not record complete state", "error", err)
}
// A request raised from the wanted list retires its want here
// A download raised from a durable Request retires it here
// rather than waiting for the next reconcile pass to notice the
// files, so the wanted list is right the moment the download
// files, so the request list is right the moment the download
// finishes. The pass would reach the same conclusion by asking the
// library; this is the same answer, sooner.
if req.WantID != 0 {
if err := m.store.SatisfyWant(ctx, req.WantID); err != nil {
if dl.RequestID != 0 {
if err := m.store.SatisfyRequest(ctx, dl.RequestID); err != nil {
m.logger.Warn(
"could not satisfy want", "want", req.WantID, "error", err,
"could not satisfy request", "request", dl.RequestID, "error", err,
)
}
}
@@ -835,7 +865,7 @@ func (m *Manager) grab(
// mid-flight. Holding it after the download completes would leak a
// few hundred candidates per request for the life of the process.
m.resMu.Lock()
delete(m.results, req.ID)
delete(m.results, dl.ID)
m.resMu.Unlock()
// Staging is only released on a fully successful import; a failure
@@ -845,10 +875,10 @@ func (m *Manager) grab(
}
if m.library != nil {
if err := m.library.ScanLibrary(req.LibraryID); err != nil {
if err := m.library.ScanLibrary(dl.LibraryID); err != nil {
m.logger.Warn(
"could not trigger scan after import",
"library", req.LibraryID,
"library", dl.LibraryID,
"error", err,
)
}
@@ -871,16 +901,16 @@ func (m *Manager) grab(
// delegates the whole thing.
func (m *Manager) transfer(
ctx context.Context,
req Request,
item Item,
dl Download,
item DownloadItem,
plan transferPlan,
job *jobs.Handle,
) (Result, error) {
if plan.delegated() {
return m.delegate(ctx, req, item, plan.delegate, job)
return m.delegate(ctx, dl, item, plan.delegate, job)
}
m.setStates(ctx, req.ID, item.ID, StateGrabbing)
m.setStates(ctx, dl.ID, item.ID, StateGrabbing)
if job != nil {
job.SetPhase("Downloading")
@@ -896,7 +926,7 @@ func (m *Manager) transfer(
return Result{}, fmt.Errorf("grab failed: %w", err)
}
m.setStates(ctx, req.ID, item.ID, StateVerifying)
m.setStates(ctx, dl.ID, item.ID, StateVerifying)
if job != nil {
job.SetPhase("Verifying")
@@ -966,7 +996,7 @@ type transferPlan struct {
func (p transferPlan) delegated() bool { return p.delegate != nil }
// planTransfer decides how a candidate will be fetched.
func (m *Manager) planTransfer(_ Request, c Candidate) (transferPlan, error) {
func (m *Manager) planTransfer(_ Download, c Candidate) (transferPlan, error) {
providers := m.enabledProviders()
source, ok := providers[c.ProviderID]
@@ -992,12 +1022,12 @@ func (m *Manager) planTransfer(_ Request, c Candidate) (transferPlan, error) {
// reports terminal state.
func (m *Manager) delegate(
ctx context.Context,
req Request,
item Item,
dl Download,
item DownloadItem,
d Delegator,
job *jobs.Handle,
) (Result, error) {
externalID, err := d.Delegate(ctx, req)
externalID, err := d.Delegate(ctx, dl)
if err != nil {
return Result{}, fmt.Errorf("delegate request: %w", err)
}
@@ -1006,7 +1036,7 @@ func (m *Manager) delegate(
m.logger.Warn("could not record external id", "error", err)
}
m.setStates(ctx, req.ID, item.ID, StateGrabbing)
m.setStates(ctx, dl.ID, item.ID, StateGrabbing)
if job != nil {
job.SetPhase("Waiting on external manager")
@@ -1104,12 +1134,12 @@ func (m *Manager) progressReporter(
}
// Candidates returns the ranked candidates for a live request.
func (m *Manager) Candidates(requestID string) []Candidate {
func (m *Manager) Candidates(downloadID string) []Candidate {
m.resMu.RLock()
defer m.resMu.RUnlock()
out := make([]Candidate, len(m.results[requestID]))
copy(out, m.results[requestID])
out := make([]Candidate, len(m.results[downloadID]))
copy(out, m.results[downloadID])
return out
}
@@ -1117,10 +1147,10 @@ func (m *Manager) Candidates(requestID string) []Candidate {
// setStates advances a request and its item together.
func (m *Manager) setStates(
ctx context.Context,
requestID, itemID string,
downloadID, itemID string,
state State,
) {
if err := m.store.SetRequestState(ctx, requestID, state, ""); err != nil {
if err := m.store.SetDownloadState(ctx, downloadID, state, ""); err != nil {
m.logger.Warn("could not set request state", "error", err)
}
@@ -1129,17 +1159,17 @@ func (m *Manager) setStates(
}
}
// failRequest records a request-level failure.
func (m *Manager) failRequest(
// failDownload records a download-level failure.
func (m *Manager) failDownload(
ctx context.Context,
job *jobs.Handle,
requestID string,
downloadID string,
err error,
) {
m.logger.Warn("download request failed", "request", requestID, "error", err)
m.logger.Warn("download failed", "download", downloadID, "error", err)
if serr := m.store.SetRequestState(
ctx, requestID, StateFailed, err.Error(),
if serr := m.store.SetDownloadState(
ctx, downloadID, StateFailed, err.Error(),
); serr != nil {
m.logger.Warn("could not record failure", "error", serr)
}
@@ -1149,12 +1179,12 @@ func (m *Manager) failRequest(
}
}
// failItem records an item-level failure and fails its request.
// failItem records an item-level failure and fails its download.
func (m *Manager) failItem(
ctx context.Context,
job *jobs.Handle,
item Item,
requestID string,
item DownloadItem,
downloadID string,
err error,
) {
if serr := m.store.SetItemState(
@@ -1163,30 +1193,30 @@ func (m *Manager) failItem(
m.logger.Warn("could not record item failure", "error", serr)
}
m.failRequest(ctx, job, requestID, err)
m.failDownload(ctx, job, downloadID, err)
}
// startJob registers the request in the background jobs panel.
func (m *Manager) startJob(req Request) *jobs.Handle {
func (m *Manager) startJob(dl Download) *jobs.Handle {
if m.jobsReg == nil {
return nil
}
title := req.Album
title := dl.Album
if title == "" {
title = req.SearchText()
title = dl.SearchText()
}
return m.jobsReg.Start(jobs.Spec{
ID: "download-" + req.ID,
ID: "download-" + dl.ID,
Kind: jobs.KindDownload,
Title: "Downloading " + title,
Subtitle: req.Artist,
Subtitle: dl.Artist,
State: jobs.StateRunning,
Caps: jobs.Caps{Cancellable: true},
Controls: jobs.Controls{
Cancel: func() {
if err := m.Cancel(context.Background(), req.ID); err != nil {
if err := m.Cancel(context.Background(), dl.ID); err != nil {
m.logger.Warn("cancel failed", "error", err)
}
},
+40 -40
View File
@@ -91,7 +91,7 @@ func TestManagerSearchRanksAcrossProviders(t *testing.T) {
f.manager.installProvider(Config{ID: 1, Priority: 50}, mp3)
f.manager.installProvider(Config{ID: 2, Priority: 50}, flac)
ranked, err := f.manager.Search(context.Background(), fourTrackRequest())
ranked, err := f.manager.Search(context.Background(), fourTrackDownload())
if err != nil {
t.Fatalf("Search: %v", err)
}
@@ -128,7 +128,7 @@ func TestManagerSearchToleratesProviderFailure(t *testing.T) {
f.manager.installProvider(Config{ID: 1, Priority: 50}, broken)
f.manager.installProvider(Config{ID: 2, Priority: 50}, working)
ranked, err := f.manager.Search(context.Background(), fourTrackRequest())
ranked, err := f.manager.Search(context.Background(), fourTrackDownload())
if err != nil {
t.Fatalf("Search: %v", err)
}
@@ -143,7 +143,7 @@ func TestManagerSearchNoProviders(t *testing.T) {
f := newManagerFixture(t)
_, err := f.manager.Search(context.Background(), fourTrackRequest())
_, err := f.manager.Search(context.Background(), fourTrackDownload())
if !errors.Is(err, ErrNoProviders) {
t.Fatalf("error = %v, want ErrNoProviders", err)
}
@@ -157,7 +157,7 @@ func TestManagerSearchNoCandidates(t *testing.T) {
empty := NewFakeProvider(1, "empty", Caps{CanSearch: true})
f.manager.installProvider(Config{ID: 1, Priority: 50}, empty)
_, err := f.manager.Search(context.Background(), fourTrackRequest())
_, err := f.manager.Search(context.Background(), fourTrackDownload())
if !errors.Is(err, ErrNoCandidates) {
t.Fatalf("error = %v, want ErrNoCandidates", err)
}
@@ -173,21 +173,21 @@ func TestManagerEndToEndAutoPick(t *testing.T) {
provider := fakeWithAlbum(1, "flac-source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
req := fourTrackRequest()
dl := fourTrackDownload()
ranked, err := f.manager.Start(context.Background(), req)
ranked, err := f.manager.Start(context.Background(), dl)
if err != nil {
t.Fatalf("Start: %v", err)
}
if !AutoPickable(req, ranked) {
if !f.manager.AutoPickable(dl, ranked) {
t.Fatalf(
"expected a clear winner to auto-pick; best match %f quality %f",
ranked[0].Match.Overall, ranked[0].Quality.Overall,
)
}
waitForRequestState(t, f.store, req.ID, StateComplete)
waitForDownloadState(t, f.store, dl.ID, StateComplete)
if provider.GrabCalls != 1 {
t.Errorf("grab calls = %d, want 1", provider.GrabCalls)
@@ -231,14 +231,14 @@ func TestManagerWaitsWhenAmbiguous(t *testing.T) {
f.manager.installProvider(Config{ID: 1, Priority: 50}, a)
f.manager.installProvider(Config{ID: 2, Priority: 50}, b)
req := fourTrackRequest()
dl := fourTrackDownload()
ranked, err := f.manager.Start(context.Background(), req)
ranked, err := f.manager.Start(context.Background(), dl)
if err != nil {
t.Fatalf("Start: %v", err)
}
if AutoPickable(req, ranked) {
if f.manager.AutoPickable(dl, ranked) {
t.Fatal("two equivalent candidates must not auto-pick")
}
@@ -250,23 +250,23 @@ func TestManagerWaitsWhenAmbiguous(t *testing.T) {
)
}
stored, err := f.store.GetRequest(context.Background(), req.ID)
stored, err := f.store.GetDownload(context.Background(), dl.ID)
if err != nil {
t.Fatalf("GetRequest: %v", err)
t.Fatalf("GetDownload: %v", err)
}
if stored.ID != req.ID {
t.Errorf("stored request id = %s, want %s", stored.ID, req.ID)
if stored.ID != dl.ID {
t.Errorf("stored request id = %s, want %s", stored.ID, dl.ID)
}
// The user picks the second one explicitly.
if err := f.manager.Pick(
context.Background(), req.ID, ranked[1].ID,
context.Background(), dl.ID, ranked[1].ID,
); err != nil {
t.Fatalf("Pick: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateComplete)
waitForDownloadState(t, f.store, dl.ID, StateComplete)
}
func TestManagerPickUnknownCandidate(t *testing.T) {
@@ -277,14 +277,14 @@ func TestManagerPickUnknownCandidate(t *testing.T) {
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
req := fourTrackRequest()
req.Expected = nil // free text: never auto-picks
dl := fourTrackDownload()
dl.Expected = nil // free text: never auto-picks
if _, err := f.manager.Start(context.Background(), req); err != nil {
if _, err := f.manager.Start(context.Background(), dl); err != nil {
t.Fatalf("Start: %v", err)
}
err := f.manager.Pick(context.Background(), req.ID, "no-such-candidate")
err := f.manager.Pick(context.Background(), dl.ID, "no-such-candidate")
if !errors.Is(err, ErrCandidateGone) {
t.Fatalf("error = %v, want ErrCandidateGone", err)
}
@@ -302,13 +302,13 @@ func TestManagerFailedGrabLeavesLibraryClean(t *testing.T) {
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
req := fourTrackRequest()
dl := fourTrackDownload()
if _, err := f.manager.Start(context.Background(), req); err != nil {
if _, err := f.manager.Start(context.Background(), dl); err != nil {
t.Fatalf("Start: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateFailed)
waitForDownloadState(t, f.store, dl.ID, StateFailed)
entries, err := os.ReadDir(f.root)
if err != nil {
@@ -355,13 +355,13 @@ func TestManagerPairsSearcherWithTransport(t *testing.T) {
f.manager.installProvider(Config{ID: 1, Priority: 50}, searcher)
f.manager.installProvider(Config{ID: 2, Priority: 50}, transport)
req := fourTrackRequest()
dl := fourTrackDownload()
if _, err := f.manager.Start(context.Background(), req); err != nil {
if _, err := f.manager.Start(context.Background(), dl); err != nil {
t.Fatalf("Start: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateComplete)
waitForDownloadState(t, f.store, dl.ID, StateComplete)
if transport.GrabCalls != 1 {
t.Errorf("transport grabs = %d, want 1", transport.GrabCalls)
@@ -387,22 +387,22 @@ func TestManagerNoTransportForProtocol(t *testing.T) {
f.manager.installProvider(Config{ID: 1, Priority: 50}, searcher)
req := fourTrackRequest()
dl := fourTrackDownload()
if _, err := f.manager.Start(context.Background(), req); err != nil {
if _, err := f.manager.Start(context.Background(), dl); err != nil {
t.Fatalf("Start: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateFailed)
waitForDownloadState(t, f.store, dl.ID, StateFailed)
}
// waitForRequestState polls until a request reaches the wanted state.
// waitForDownloadState polls until a download reaches the wanted state.
// The pipeline runs on its own goroutine, so tests observe it through
// the store rather than by reaching into the manager.
func waitForRequestState(
func waitForDownloadState(
t *testing.T,
store *Store,
requestID string,
downloadID string,
want State,
) {
t.Helper()
@@ -412,7 +412,7 @@ func waitForRequestState(
var last State
for time.Now().Before(deadline) {
state, _, err := store.GetRequestState(context.Background(), requestID)
state, _, err := store.GetDownloadState(context.Background(), downloadID)
if err == nil {
last = state
if last == want {
@@ -423,7 +423,7 @@ func waitForRequestState(
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("request state = %q after 5s, want %q", last, want)
t.Fatalf("download state = %q after 5s, want %q", last, want)
}
// A delegate's files are already in the external manager's library,
@@ -457,13 +457,13 @@ func TestManagerDelegateReconcilesInPlace(t *testing.T) {
f.manager.installProvider(Config{ID: 1, Priority: 50}, delegate)
req := fourTrackRequest()
dl := fourTrackDownload()
if _, err := f.manager.Start(context.Background(), req); err != nil {
if _, err := f.manager.Start(context.Background(), dl); err != nil {
t.Fatalf("Start: %v", err)
}
waitForRequestState(t, f.store, req.ID, StateComplete)
waitForDownloadState(t, f.store, dl.ID, StateComplete)
if delegate.DelegateCalls != 1 {
t.Errorf("delegate calls = %d, want 1", delegate.DelegateCalls)
@@ -489,9 +489,9 @@ func TestManagerDelegateReconcilesInPlace(t *testing.T) {
}
// The external paths were recorded against the item.
items, err := f.store.ListItemsForRequest(context.Background(), req.ID)
items, err := f.store.ListItemsForDownload(context.Background(), dl.ID)
if err != nil {
t.Fatalf("ListItemsForRequest: %v", err)
t.Fatalf("ListItemsForDownload: %v", err)
}
if len(items) != 1 {
+11 -11
View File
@@ -50,7 +50,7 @@ type Provider interface {
// respect ctx deadlines: the pipeline searches providers concurrently
// with a per-provider timeout and takes whatever came back in time.
type Searcher interface {
Search(ctx context.Context, req Request) ([]Candidate, error)
Search(ctx context.Context, dl Download) ([]Candidate, error)
}
// Transporter moves a candidate's bytes into dst, which the pipeline
@@ -73,7 +73,7 @@ type Transporter interface {
// the manager reports terminal state.
type Delegator interface {
// Delegate submits the request and returns the manager's own ID.
Delegate(ctx context.Context, req Request) (string, error)
Delegate(ctx context.Context, dl Download) (string, error)
// Poll reports on a previously delegated request.
Poll(ctx context.Context, externalID string) (DelegateStatus, error)
@@ -92,18 +92,18 @@ type Delegator interface {
// pushes, the external system receives. Pulling happens only when the
// user explicitly imports.
type Lister interface {
// PushWant records a want in the provider's own list and returns
// the provider's identifier for it. Implementations must be
// idempotent: pushing a want the provider already has returns the
// existing identifier rather than duplicating it.
PushWant(ctx context.Context, w Want) (string, error)
// PushRequest records a request in the provider's own list and
// returns the provider's identifier for it. Implementations must
// be idempotent: pushing a request the provider already has returns
// the existing identifier rather than duplicating it.
PushRequest(ctx context.Context, r Request) (string, error)
// RemoveWant drops a previously pushed want. Best-effort.
RemoveWant(ctx context.Context, externalID string) error
// RemoveRequest drops a previously pushed request. Best-effort.
RemoveRequest(ctx context.Context, externalID string) error
// ListWants reads the provider's list back, for the deliberate
// ListRequests reads the provider's list back, for the deliberate
// import path. LibraryID is filled in by the caller.
ListWants(ctx context.Context) ([]Want, error)
ListRequests(ctx context.Context) ([]Request, error)
}
// ProviderInfo is a provider's identity as the frontend sees it.
+7 -7
View File
@@ -237,8 +237,8 @@ type lidarrTrackFile struct {
// Delegate adds the album to Lidarr, monitors it and triggers a search.
// The external ID returned is Lidarr's album ID, which is what Poll
// needs and what survives a restart.
func (l *lidarr) Delegate(ctx context.Context, req Request) (string, error) {
album, err := l.findAlbum(ctx, req)
func (l *lidarr) Delegate(ctx context.Context, dl Download) (string, error) {
album, err := l.findAlbum(ctx, dl)
if err != nil {
return "", err
}
@@ -276,12 +276,12 @@ func (l *lidarr) Delegate(ctx context.Context, req Request) (string, error) {
// not.
func (l *lidarr) findAlbum(
ctx context.Context,
req Request,
dl Download,
) (lidarrAlbum, error) {
term := req.SearchText()
term := dl.SearchText()
if req.ReleaseGroupMBID != "" {
term = "lidarr:" + req.ReleaseGroupMBID
if dl.ReleaseGroupMBID != "" {
term = "lidarr:" + dl.ReleaseGroupMBID
}
var results []struct {
@@ -300,7 +300,7 @@ func (l *lidarr) findAlbum(
}
}
return lidarrAlbum{}, fmt.Errorf("%w: %s", ErrLidarrNoMatch, req.SearchText())
return lidarrAlbum{}, fmt.Errorf("%w: %s", ErrLidarrNoMatch, dl.SearchText())
}
// addArtistForAlbum adds the album's artist so the album becomes a real
+34 -34
View File
@@ -7,10 +7,10 @@ import (
"strconv"
)
// Lidarr's Lister role: mirroring this app's wanted list into Lidarr's
// Lidarr's Lister role: mirroring this app's request list into Lidarr's
// own monitoring.
//
// Lidarr already models exactly what a want is — a monitored artist or
// Lidarr already models exactly what a request is — a monitored artist or
// a monitored album — so the mapping is direct and, more usefully, it
// means an artist subscription made here keeps working through Lidarr's
// own release-checking even while this app is closed. That is the
@@ -21,52 +21,52 @@ import (
//
// The mapping is deliberately lossy in one direction only:
//
// artist want -> Lidarr artist, monitored
// release-group want -> Lidarr album, monitored (artist added if new)
// release want -> same, at release-group granularity
// recording want -> not pushed; Lidarr has no concept of wanting
// artist request -> Lidarr artist, monitored
// release-group request -> Lidarr album, monitored (artist added if new)
// release request -> same, at release-group granularity
// recording request -> not pushed; Lidarr has no concept of wanting
// one track, and monitoring the whole album to
// get it would download far more than asked.
//
// Nothing here searches. Pushing a want expresses intent; Lidarr
// Nothing here searches. Pushing a request expresses intent; Lidarr
// decides when to act on it, which is the point of delegating.
// PushWant records a want in Lidarr's own monitoring.
// PushRequest records a request in Lidarr's own monitoring.
//
// It is idempotent because Lidarr is: adding an artist that already
// exists returns the existing record, and monitoring an already-
// monitored album is a no-op. Callers rely on that — the reconciler
// pushes on every pass until it gets an ID back.
func (l *lidarr) PushWant(ctx context.Context, w Want) (string, error) {
switch w.Entity {
func (l *lidarr) PushRequest(ctx context.Context, r Request) (string, error) {
switch r.Entity {
case EntityArtist:
return l.pushArtistWant(ctx, w)
return l.pushArtistRequest(ctx, r)
case EntityReleaseGroup, EntityRelease:
return l.pushAlbumWant(ctx, w)
return l.pushAlbumRequest(ctx, r)
case EntityRecording:
// Deliberately unsupported rather than approximated. See the
// mapping note above.
return "", nil
default:
return "", fmt.Errorf("%w: entity %q", ErrUnsupported, w.Entity)
return "", fmt.Errorf("%w: entity %q", ErrUnsupported, r.Entity)
}
}
// pushArtistWant makes Lidarr monitor an artist.
// pushArtistRequest makes Lidarr monitor an artist.
//
// Scope is honoured through Lidarr's own monitor option rather than by
// pushing each album separately: "future" maps to monitoring new
// releases only, "all" to monitoring everything missing. Letting
// Lidarr apply the policy means it stays applied to albums released
// after this push, which is what a subscription is for.
func (l *lidarr) pushArtistWant(ctx context.Context, w Want) (string, error) {
existing, err := l.findArtistByMBID(ctx, w.MBID)
func (l *lidarr) pushArtistRequest(ctx context.Context, r Request) (string, error) {
existing, err := l.findArtistByMBID(ctx, r.MBID)
if err != nil {
return "", err
}
monitor := "future"
if w.Scope == ScopeAll {
if r.Scope == ScopeAll {
monitor = "missing"
}
@@ -84,13 +84,13 @@ func (l *lidarr) pushArtistWant(ctx context.Context, w Want) (string, error) {
return "", err
}
name := w.Artist
name := r.Artist
if name == "" {
name = w.Title
name = r.Title
}
body := map[string]any{
"foreignArtistId": w.MBID,
"foreignArtistId": r.MBID,
"artistName": name,
"qualityProfileId": quality,
"metadataProfileId": metadata,
@@ -118,13 +118,13 @@ func (l *lidarr) pushArtistWant(ctx context.Context, w Want) (string, error) {
return strconv.Itoa(created.ID), nil
}
// pushAlbumWant makes Lidarr monitor one album, adding its artist if
// pushAlbumRequest makes Lidarr monitor one album, adding its artist if
// Lidarr has never heard of them.
func (l *lidarr) pushAlbumWant(ctx context.Context, w Want) (string, error) {
album, err := l.findAlbum(ctx, Request{
ReleaseGroupMBID: w.MBID,
Artist: w.Artist,
Album: w.Title,
func (l *lidarr) pushAlbumRequest(ctx context.Context, r Request) (string, error) {
album, err := l.findAlbum(ctx, Download{
ReleaseGroupMBID: r.MBID,
Artist: r.Artist,
Album: r.Title,
})
if err != nil {
return "", err
@@ -146,13 +146,13 @@ func (l *lidarr) pushAlbumWant(ctx context.Context, w Want) (string, error) {
return strconv.Itoa(album.ID), nil
}
// RemoveWant stops Lidarr monitoring something.
// RemoveRequest stops Lidarr monitoring something.
//
// It unmonitors rather than deletes: the user's Lidarr may have been
// monitoring that artist long before this app existed, and removing a
// want here is not permission to tear down their setup. An unmonitored
// request here is not permission to tear down their setup. An unmonitored
// artist stays in their library with its files intact.
func (l *lidarr) RemoveWant(ctx context.Context, externalID string) error {
func (l *lidarr) RemoveRequest(ctx context.Context, externalID string) error {
id, err := strconv.Atoi(externalID)
if err != nil {
return fmt.Errorf("%w: bad lidarr id %q", ErrLidarrNoMatch, externalID)
@@ -180,14 +180,14 @@ func (l *lidarr) RemoveWant(ctx context.Context, externalID string) error {
return l.client.put(ctx, endpoint, artist, nil)
}
// ListWants reads Lidarr's monitored artists back, for the deliberate
// ListRequests reads Lidarr's monitored artists back, for the deliberate
// "import what Lidarr is already watching" action.
//
// Only artists are imported, not their individual monitored albums: an
// artist is the durable statement of intent, and importing every
// monitored album alongside it would produce a wanted list that is
// monitored album alongside it would produce a request list that is
// mostly redundant with the subscription that generated it.
func (l *lidarr) ListWants(ctx context.Context) ([]Want, error) {
func (l *lidarr) ListRequests(ctx context.Context) ([]Request, error) {
var artists []struct {
lidarrArtist
@@ -198,14 +198,14 @@ func (l *lidarr) ListWants(ctx context.Context) ([]Want, error) {
return nil, err
}
out := make([]Want, 0, len(artists))
out := make([]Request, 0, len(artists))
for _, a := range artists {
if !a.Monitored || a.ForeignArtistID == "" {
continue
}
out = append(out, Want{
out = append(out, Request{
MBID: a.ForeignArtistID,
Entity: EntityArtist,
Artist: a.ArtistName,
+25 -25
View File
@@ -9,12 +9,12 @@ import (
// scope translated into Lidarr's own monitor option — so the policy
// keeps applying to albums released after the push, which is the whole
// reason to mirror a subscription rather than a list of albums.
func TestLidarrPushArtistWantMapsScope(t *testing.T) {
func TestLidarrPushArtistRequestMapsScope(t *testing.T) {
t.Parallel()
tests := []struct {
name string
scope WantScope
scope RequestScope
wantMonitor string
}{
{name: "future", scope: ScopeFuture, wantMonitor: "future"},
@@ -25,14 +25,14 @@ func TestLidarrPushArtistWantMapsScope(t *testing.T) {
stub := newLidarrStub(t)
l := newStubLidarr(t, stub)
id, err := l.PushWant(context.Background(), Want{
id, err := l.PushRequest(context.Background(), Request{
MBID: "artist-mbid",
Entity: EntityArtist,
Artist: "Radiohead",
Scope: tt.scope,
})
if err != nil {
t.Fatalf("%s: PushWant: %v", tt.name, err)
t.Fatalf("%s: PushRequest: %v", tt.name, err)
}
if id != "42" {
@@ -70,7 +70,7 @@ func TestLidarrPushArtistWantMapsScope(t *testing.T) {
// Pushing a want Lidarr already has returns the existing ID instead of
// adding a second copy — the reconciler pushes on every pass, so this
// is load-bearing rather than tidy.
func TestLidarrPushArtistWantIsIdempotent(t *testing.T) {
func TestLidarrPushArtistRequestIsIdempotent(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
@@ -83,13 +83,13 @@ func TestLidarrPushArtistWantIsIdempotent(t *testing.T) {
l := newStubLidarr(t, stub)
id, err := l.PushWant(context.Background(), Want{
id, err := l.PushRequest(context.Background(), Request{
MBID: "artist-mbid",
Entity: EntityArtist,
Artist: "Radiohead",
})
if err != nil {
t.Fatalf("PushWant: %v", err)
t.Fatalf("PushRequest: %v", err)
}
if id != "7" {
@@ -107,19 +107,19 @@ func TestLidarrPushArtistWantIsIdempotent(t *testing.T) {
// Lidarr cannot express "I want one track", and monitoring the whole
// album to get it would download far more than was asked for.
func TestLidarrPushRecordingWantIsSkipped(t *testing.T) {
func TestLidarrPushRecordingRequestIsSkipped(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
l := newStubLidarr(t, stub)
id, err := l.PushWant(context.Background(), Want{
id, err := l.PushRequest(context.Background(), Request{
MBID: "recording-mbid",
Entity: EntityRecording,
Title: "Paranoid Android",
})
if err != nil {
t.Fatalf("PushWant: %v", err)
t.Fatalf("PushRequest: %v", err)
}
if id != "" {
@@ -141,7 +141,7 @@ func TestLidarrPushRecordingWantIsSkipped(t *testing.T) {
// Importing adopts monitored artists conservatively: a subscription
// pulled in from elsewhere must not queue a back catalogue.
func TestLidarrListWantsImportsMonitoredArtistsOnly(t *testing.T) {
func TestLidarrListRequestsImportsMonitoredArtistsOnly(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
@@ -168,40 +168,40 @@ func TestLidarrListWantsImportsMonitoredArtistsOnly(t *testing.T) {
l := newStubLidarr(t, stub)
wants, err := l.ListWants(context.Background())
requests, err := l.ListRequests(context.Background())
if err != nil {
t.Fatalf("ListWants: %v", err)
t.Fatalf("ListRequests: %v", err)
}
if len(wants) != 1 {
t.Fatalf("imported %d wants, want 1", len(wants))
if len(requests) != 1 {
t.Fatalf("imported %d requests, want 1", len(requests))
}
w := wants[0]
req := requests[0]
if w.MBID != "artist-1" {
t.Errorf("mbid = %q, want artist-1", w.MBID)
if req.MBID != "artist-1" {
t.Errorf("mbid = %q, want artist-1", req.MBID)
}
if w.Entity != EntityArtist {
t.Errorf("entity = %q, want artist", w.Entity)
if req.Entity != EntityArtist {
t.Errorf("entity = %q, want artist", req.Entity)
}
if w.Scope != ScopeFuture {
t.Errorf("scope = %q, want the conservative future", w.Scope)
if req.Scope != ScopeFuture {
t.Errorf("scope = %q, want the conservative future", req.Scope)
}
}
// Removing a want must not tear down a Lidarr setup that may predate
// this app: it unmonitors, it does not delete.
func TestLidarrRemoveWantUnmonitorsOnly(t *testing.T) {
func TestLidarrRemoveRequestUnmonitorsOnly(t *testing.T) {
t.Parallel()
stub := newLidarrStub(t)
l := newStubLidarr(t, stub)
if err := l.RemoveWant(context.Background(), "55"); err != nil {
t.Fatalf("RemoveWant: %v", err)
if err := l.RemoveRequest(context.Background(), "55"); err != nil {
t.Fatalf("RemoveRequest: %v", err)
}
stub.mu.Lock()
+3 -3
View File
@@ -327,7 +327,7 @@ func TestLidarrDelegateExistingAlbum(t *testing.T) {
l := newStubLidarr(t, stub)
externalID, err := l.Delegate(context.Background(), Request{
externalID, err := l.Delegate(context.Background(), Download{
ReleaseGroupMBID: "rg-mbid",
Artist: "Radiohead",
Album: "OK Computer",
@@ -373,7 +373,7 @@ func TestLidarrDelegateNewArtistMonitorsNothingByDefault(t *testing.T) {
l := newStubLidarr(t, stub)
if _, err := l.Delegate(context.Background(), Request{
if _, err := l.Delegate(context.Background(), Download{
ReleaseGroupMBID: "rg-mbid",
Artist: "Radiohead",
Album: "OK Computer",
@@ -414,7 +414,7 @@ func TestLidarrDelegateNoMatch(t *testing.T) {
l := newStubLidarr(t, stub)
_, err := l.Delegate(context.Background(), Request{
_, err := l.Delegate(context.Background(), Download{
Artist: "Nobody",
Album: "Nothing",
})
+2 -2
View File
@@ -210,10 +210,10 @@ type prowlarrResult struct {
// Search queries every configured indexer through Prowlarr.
func (p *prowlarr) Search(
ctx context.Context,
req Request,
dl Download,
) ([]Candidate, error) {
query := url.Values{}
query.Set("query", req.SearchText())
query.Set("query", dl.SearchText())
query.Set("categories", prowlarrMusicCategory)
query.Set("type", "search")
+3 -3
View File
@@ -169,7 +169,7 @@ func TestProwlarrSearchMarksProtocols(t *testing.T) {
p := newStubProwlarr(t, stub, nil)
got, err := p.Search(context.Background(), Request{
got, err := p.Search(context.Background(), Download{
Artist: "Radiohead",
Album: "OK Computer",
})
@@ -230,7 +230,7 @@ func TestProwlarrFiltersDeadTorrents(t *testing.T) {
p := newStubProwlarr(t, stub, map[string]string{"minSeeders": "1"})
got, err := p.Search(context.Background(), Request{Query: "x"})
got, err := p.Search(context.Background(), Download{Query: "x"})
if err != nil {
t.Fatalf("Search: %v", err)
}
@@ -253,7 +253,7 @@ func TestProwlarrSearchesMusicCategory(t *testing.T) {
p := newStubProwlarr(t, stub, nil)
if _, err := p.Search(
context.Background(), Request{Query: "radiohead"},
context.Background(), Download{Query: "radiohead"},
); err != nil {
t.Fatalf("Search: %v", err)
}
+2 -2
View File
@@ -278,7 +278,7 @@ func (t slskdTransfer) done() (finished, ok bool) {
// per-folder candidates. A folder from one peer is the unit a user
// actually wants: Soulseek has no album concept, but people organise
// their shares by album directory.
func (s *slskd) Search(ctx context.Context, req Request) ([]Candidate, error) {
func (s *slskd) Search(ctx context.Context, dl Download) ([]Candidate, error) {
// slskd's search endpoint deserializes id as a .NET Guid server-side,
// so it must be a dashed UUID — the app's own newID() (a plain hex
// string, used for request/item IDs elsewhere) is rejected with an
@@ -287,7 +287,7 @@ func (s *slskd) Search(ctx context.Context, req Request) ([]Candidate, error) {
body := map[string]any{
"id": searchID,
"searchText": req.SearchText(),
"searchText": dl.SearchText(),
}
if err := s.client.post(ctx, "/api/v0/searches", body, nil); err != nil {
+2 -2
View File
@@ -288,7 +288,7 @@ func TestSlskdGroupsResultsByPeerAndFolder(t *testing.T) {
s, _ := newStubSlskd(t, stub)
got, err := s.Search(context.Background(), Request{
got, err := s.Search(context.Background(), Download{
Artist: "Radiohead",
Album: "OK Computer",
})
@@ -354,7 +354,7 @@ func TestSlskdSkipsTinyFolders(t *testing.T) {
s, _ := newStubSlskd(t, stub)
got, err := s.Search(context.Background(), Request{Query: "x"})
got, err := s.Search(context.Background(), Download{Query: "x"})
if err != nil {
t.Fatalf("Search: %v", err)
}
+11 -11
View File
@@ -201,9 +201,9 @@ func (e ytEntry) link() string {
// how yt-dlp is actually useful for albums — a single "full album"
// video is one file and cannot be imported as tracks. Without a
// tracklist it falls back to returning the top individual results.
func (y *ytDlp) Search(ctx context.Context, req Request) ([]Candidate, error) {
if len(req.Expected) > 0 {
c, err := y.assembleAlbum(ctx, req)
func (y *ytDlp) Search(ctx context.Context, dl Download) ([]Candidate, error) {
if len(dl.Expected) > 0 {
c, err := y.assembleAlbum(ctx, dl)
if err != nil {
return nil, err
}
@@ -213,7 +213,7 @@ func (y *ytDlp) Search(ctx context.Context, req Request) ([]Candidate, error) {
}
}
entries, err := y.search(ctx, req.SearchText(), ytSearchCount)
entries, err := y.search(ctx, dl.SearchText(), ytSearchCount)
if err != nil {
return nil, err
}
@@ -257,7 +257,7 @@ func (y *ytDlp) Search(ctx context.Context, req Request) ([]Candidate, error) {
// threshold decides whether what arrived is enough.
func (y *ytDlp) assembleAlbum(
ctx context.Context,
req Request,
dl Download,
) (Candidate, error) {
type hit struct {
index int
@@ -272,10 +272,10 @@ func (y *ytDlp) assembleAlbum(
group, gctx := errgroup.WithContext(ctx)
group.SetLimit(ytTrackConcurrency)
for i, track := range req.Expected {
for i, track := range dl.Expected {
group.Go(func() error {
query := strings.TrimSpace(
req.Artist + " " + track.Title,
dl.Artist + " " + track.Title,
)
entries, err := y.search(gctx, query, 1)
@@ -300,11 +300,11 @@ func (y *ytDlp) assembleAlbum(
}
c := Candidate{
ID: "ytdlp:album:" + req.ID,
ID: "ytdlp:album:" + dl.ID,
Kind: KindYtDlp,
Protocol: ProtocolDirect,
Title: req.Album,
Artist: req.Artist,
Title: dl.Album,
Artist: dl.Artist,
Origin: "yt-dlp (assembled per track)",
Health: 0.75,
Payload: map[string]string{},
@@ -312,7 +312,7 @@ func (y *ytDlp) assembleAlbum(
}
for _, h := range hits {
track := req.Expected[h.index]
track := dl.Expected[h.index]
link := h.entry.link()
if link == "" {
+6 -6
View File
@@ -130,7 +130,7 @@ cat <<'EOF'
EOF
`)
got, err := y.Search(context.Background(), Request{
got, err := y.Search(context.Background(), Download{
Artist: "Radiohead",
Album: "OK Computer",
})
@@ -173,7 +173,7 @@ not json at all
EOF
`)
got, err := y.Search(context.Background(), Request{Query: "radiohead"})
got, err := y.Search(context.Background(), Download{Query: "radiohead"})
if err != nil {
t.Fatalf("Search: %v", err)
}
@@ -193,8 +193,8 @@ func TestYtDlpAssemblesAlbumFromTracklist(t *testing.T) {
echo '{"id":"x","title":"whatever the uploader called it","webpage_url":"https://example.com/x","filesize_approx":4000000}'
`)
req := Request{
ID: "req-1",
dl := Download{
ID: "dl-1",
ReleaseMBID: "mbid-1",
Artist: "Radiohead",
Album: "OK Computer",
@@ -205,7 +205,7 @@ echo '{"id":"x","title":"whatever the uploader called it","webpage_url":"https:/
},
}
got, err := y.Search(context.Background(), req)
got, err := y.Search(context.Background(), dl)
if err != nil {
t.Fatalf("Search: %v", err)
}
@@ -254,7 +254,7 @@ case "$*" in
esac
`)
got, err := y.Search(context.Background(), Request{
got, err := y.Search(context.Background(), Download{
ID: "req-1",
ReleaseMBID: "mbid-1",
Artist: "Radiohead",
+137 -23
View File
@@ -34,12 +34,13 @@ const (
weightArtistFit = 0.12
)
// Quality sub-weights.
// Quality sub-weights. They sum to 1.0 along with weightSizeFit below.
const (
weightFormat = 0.45
weightBitrate = 0.25
weightFormat = 0.42
weightBitrate = 0.23
weightHealth = 0.20
weightPriority = 0.10
weightSizeFit = 0.05
)
// unanchoredCap bounds the match score of a free-text request. Without
@@ -47,20 +48,119 @@ const (
// looking score would be a lie — and auto-pick keys off this.
const unanchoredCap = 0.65
// AutoDownloadPrefs gates and scores what AutoPickable may choose
// without asking. Zero values are permissive: no size window and no
// format restriction.
type AutoDownloadPrefs struct {
// MinSizeMB and MaxSizeMB bound what auto-pick will grab. Zero
// means no bound on that side. A candidate outside the window is
// filtered out of auto-pick entirely, not merely scored down — a
// tiny "sampler" torrent or a boxset ten times the expected size is
// usually the wrong thing entirely, not a worse copy of the right
// thing.
MinSizeMB int `json:"minSizeMb"`
MaxSizeMB int `json:"maxSizeMb"`
// PreferredSizeMB nudges the score toward a target size within the
// min/max window (a lossless rip and a heavily-padded lossless rip
// can both pass the window). Zero disables the nudge; sizeFit then
// returns a neutral value that does not affect ranking.
PreferredSizeMB int `json:"preferredSizeMb"`
// AllowedFormats restricts auto-pick to candidates whose audio
// files are all in one of these formats. Empty means no
// restriction.
AllowedFormats []Format `json:"allowedFormats"`
}
// eligible reports whether a candidate may be auto-picked under these
// preferences: within the size window (when set) and, when a format
// list is given, every audio file in an allowed format.
func (p AutoDownloadPrefs) eligible(c Candidate) bool {
const bytesPerMB = 1 << 20
if p.MinSizeMB > 0 && c.TotalSize < int64(p.MinSizeMB)*bytesPerMB {
return false
}
if p.MaxSizeMB > 0 && c.TotalSize > int64(p.MaxSizeMB)*bytesPerMB {
return false
}
if len(p.AllowedFormats) == 0 {
return true
}
allowed := make(map[Format]bool, len(p.AllowedFormats))
for _, f := range p.AllowedFormats {
allowed[f] = true
}
for _, f := range c.AudioFiles() {
if !allowed[f.Format] {
return false
}
}
return true
}
// filter returns only the candidates these preferences allow to be
// auto-picked, in the same (already ranked) order.
func (p AutoDownloadPrefs) filter(ranked []Candidate) []Candidate {
out := make([]Candidate, 0, len(ranked))
for _, c := range ranked {
if p.eligible(c) {
out = append(out, c)
}
}
return out
}
// sizeFit scores how close totalSize is to PreferredSizeMB, 0..1,
// falling off linearly as the size doubles or halves away from it.
// Returns a neutral 0.5 when no preference is set, so the absence of a
// preference does not bias ranking.
func (p AutoDownloadPrefs) sizeFit(totalSize int64) float64 {
const (
bytesPerMB = 1 << 20
neutral = 0.5
)
if p.PreferredSizeMB <= 0 || totalSize <= 0 {
return neutral
}
preferred := float64(p.PreferredSizeMB) * bytesPerMB
ratio := float64(totalSize) / preferred
if ratio < 1 {
ratio = 1 / ratio
}
// ratio is now >= 1: 1.0 is an exact match, 2.0 is double or half
// the preferred size. Falls to 0 at 2x away and beyond.
fit := 1 - (ratio - 1)
return clamp01(fit)
}
// Score fills a candidate's Match, Quality and Score fields.
func Score(req Request, c Candidate, priority int) Candidate {
func Score(dl Download, c Candidate, priority int, prefs AutoDownloadPrefs) Candidate {
c.Files = AnnotateFiles(c.Files)
audio := c.AudioFiles()
matched, titleFit := matchFiles(audio, req.Expected)
matched, titleFit := matchFiles(audio, dl.Expected)
// Write the alignment back so the picker can show which file maps
// to which track.
c.Files = mergeMatched(c.Files, matched)
c.Match = scoreMatch(req, c, audio, titleFit)
c.Quality = scoreQuality(c, audio, priority)
c.Match = scoreMatch(dl, c, audio, titleFit)
c.Quality = scoreQuality(c, audio, priority, prefs)
c.Score = weightMatch*c.Match.Overall + weightQuality*c.Quality.Overall
@@ -69,17 +169,17 @@ func Score(req Request, c Candidate, priority int) Candidate {
// scoreMatch answers whether this candidate is the requested release.
func scoreMatch(
req Request,
dl Download,
c Candidate,
audio []CandidateFile,
titleFit float64,
) MatchScore {
m := MatchScore{
Anchored: req.Anchored(),
Anchored: dl.Anchored(),
TitleFit: titleFit,
}
m.Completeness = completeness(len(audio), len(req.Expected))
m.Completeness = completeness(len(audio), len(dl.Expected))
// The candidate's own title, and the folder its files sit in, are
// two independent guesses at the album name. Take the better one:
@@ -90,16 +190,16 @@ func scoreMatch(
}
m.AlbumFit = math.Max(
autotag.TitleSimilarity(req.Album, c.Title),
autotag.TitleSimilarity(req.Album, folder),
autotag.TitleSimilarity(dl.Album, c.Title),
autotag.TitleSimilarity(dl.Album, folder),
)
m.ArtistFit = artistFit(req.Artist, c)
m.ArtistFit = artistFit(dl.Artist, c)
// With no expected tracklist there is no title signal at all, so
// redistribute its weight onto the album/artist evidence rather
// than scoring every free-text result as half-wrong.
if len(req.Expected) == 0 {
if len(dl.Expected) == 0 {
m.Overall = 0.55*m.AlbumFit + 0.45*m.ArtistFit
} else {
m.Overall = weightTitleFit*m.TitleFit +
@@ -178,10 +278,12 @@ func scoreQuality(
c Candidate,
audio []CandidateFile,
priority int,
prefs AutoDownloadPrefs,
) QualityScore {
q := QualityScore{
Health: clamp01(c.Health),
Priority: clamp01(float64(priority) / 100.0),
SizeFit: prefs.sizeFit(c.TotalSize),
}
if len(audio) == 0 {
@@ -211,7 +313,8 @@ func scoreQuality(
q.Overall = weightFormat*q.FormatRank +
weightBitrate*q.Bitrate +
weightHealth*q.Health +
weightPriority*q.Priority
weightPriority*q.Priority +
weightSizeFit*q.SizeFit
if q.Mixed {
q.Overall *= 0.9
@@ -316,9 +419,10 @@ func lossyBitrateScore(kbps int) float64 {
// on match, then on provider priority, then on file count, so the order
// is stable across runs rather than map-iteration dependent.
func Rank(
req Request,
dl Download,
candidates []Candidate,
priority func(providerID int64) int,
prefs AutoDownloadPrefs,
) []Candidate {
out := make([]Candidate, 0, len(candidates))
@@ -328,7 +432,7 @@ func Rank(
p = priority(c.ProviderID)
}
out = append(out, Score(req, c, p))
out = append(out, Score(dl, c, p, prefs))
}
sort.SliceStable(out, func(i, j int) bool {
@@ -354,31 +458,41 @@ func Rank(
// to grab without asking. It demands an anchored request, a high match,
// decent quality, and daylight between first and second place — if two
// candidates are close, the choice is the user's.
func AutoPickable(req Request, ranked []Candidate) bool {
func AutoPickable(dl Download, ranked []Candidate, prefs AutoDownloadPrefs) bool {
const (
minMatch = 0.85
minQuality = 0.5
minLead = 0.08
)
if !req.Anchored() || len(ranked) == 0 {
if !dl.Anchored() || len(ranked) == 0 {
return false
}
// An anchor with no tracklist behind it is an anchor in name only:
// the match score then rests on album and artist text alone, which
// is exactly the evidence a wrong-album candidate also has. This
// matters most for the wanted list, where nobody is watching.
if len(req.Expected) == 0 {
// matters most for the request list, where nobody is watching.
if len(dl.Expected) == 0 {
return false
}
best := ranked[0]
// The guardrails apply before the match/quality/lead checks: a
// candidate outside the allowed size or format is not a worse
// choice, it is not a choice auto-pick may make at all, so it must
// not count as "the winner" nor as "second place" for the lead
// check below.
eligible := prefs.filter(ranked)
if len(eligible) == 0 {
return false
}
best := eligible[0]
if best.Match.Overall < minMatch || best.Quality.Overall < minQuality {
return false
}
if len(ranked) > 1 && best.Score-ranked[1].Score < minLead {
if len(eligible) > 1 && best.Score-eligible[1].Score < minLead {
return false
}
+160 -22
View File
@@ -3,8 +3,8 @@ package download
import "testing"
// okComputer is the reference request used across ranking tests.
func okComputer() Request {
return Request{
func okComputer() Download {
return Download{
ReleaseMBID: "mbid-ok-computer",
Artist: "Radiohead",
Album: "OK Computer",
@@ -53,7 +53,7 @@ func allTitles() []string {
func TestRankPrefersQualityAtEqualMatch(t *testing.T) {
t.Parallel()
req := okComputer()
dl := okComputer()
flac := candidateFor("flac", allTitles(), ".flac", 30_000_000)
mp3 := candidateFor("mp3", allTitles(), ".mp3", 3_000_000)
@@ -62,7 +62,7 @@ func TestRankPrefersQualityAtEqualMatch(t *testing.T) {
mp3.Files[i].Bitrate = 128
}
ranked := Rank(req, []Candidate{mp3, flac}, nil)
ranked := Rank(dl, []Candidate{mp3, flac}, nil, AutoDownloadPrefs{})
if ranked[0].ID != "flac" {
t.Fatalf("winner = %s, want flac", ranked[0].ID)
@@ -83,7 +83,7 @@ func TestRankPrefersQualityAtEqualMatch(t *testing.T) {
func TestRankMatchDominatesQuality(t *testing.T) {
t.Parallel()
req := okComputer()
dl := okComputer()
// Right album, poor bitrate.
right := candidateFor("right", allTitles(), ".mp3", 2_000_000)
@@ -103,7 +103,7 @@ func TestRankMatchDominatesQuality(t *testing.T) {
trackToken(i+1) + " - x.flac"
}
ranked := Rank(req, []Candidate{wrong, right}, nil)
ranked := Rank(dl, []Candidate{wrong, right}, nil, AutoDownloadPrefs{})
if ranked[0].ID != "right" {
t.Fatalf(
@@ -116,12 +116,12 @@ func TestRankMatchDominatesQuality(t *testing.T) {
func TestIncompleteCandidateScoresLower(t *testing.T) {
t.Parallel()
req := okComputer()
dl := okComputer()
full := candidateFor("full", allTitles(), ".flac", 30_000_000)
partial := candidateFor("partial", allTitles()[:2], ".flac", 30_000_000)
ranked := Rank(req, []Candidate{partial, full}, nil)
ranked := Rank(dl, []Candidate{partial, full}, nil, AutoDownloadPrefs{})
if ranked[0].ID != "full" {
t.Fatalf("winner = %s, want full", ranked[0].ID)
@@ -138,7 +138,7 @@ func TestIncompleteCandidateScoresLower(t *testing.T) {
func TestMixedFormatIsPenalized(t *testing.T) {
t.Parallel()
req := okComputer()
dl := okComputer()
clean := candidateFor("clean", allTitles(), ".flac", 30_000_000)
@@ -146,7 +146,7 @@ func TestMixedFormatIsPenalized(t *testing.T) {
mixed.Files[2].Path = "Radiohead - OK Computer/03 - x.mp3"
mixed.Files[2].Format = FormatUnknown
ranked := Rank(req, []Candidate{mixed, clean}, nil)
ranked := Rank(dl, []Candidate{mixed, clean}, nil, AutoDownloadPrefs{})
var mixedScore QualityScore
@@ -170,10 +170,10 @@ func TestMixedFormatIsPenalized(t *testing.T) {
func TestUnanchoredMatchIsCapped(t *testing.T) {
t.Parallel()
req := Request{Artist: "Radiohead", Album: "OK Computer"}
dl := Download{Artist: "Radiohead", Album: "OK Computer"}
c := candidateFor("c", allTitles(), ".flac", 30_000_000)
scored := Score(req, c, 50)
scored := Score(dl, c, 50, AutoDownloadPrefs{})
if scored.Match.Anchored {
t.Error("free-text request reported as anchored")
@@ -190,19 +190,20 @@ func TestUnanchoredMatchIsCapped(t *testing.T) {
func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
t.Parallel()
req := okComputer()
best := Score(req, candidateFor("a", allTitles(), ".flac", 30_000_000), 50)
dl := okComputer()
best := Score(dl, candidateFor("a", allTitles(), ".flac", 30_000_000), 50, AutoDownloadPrefs{})
t.Run("clear winner is auto-pickable", func(t *testing.T) {
t.Parallel()
weak := Score(
req,
dl,
candidateFor("b", allTitles()[:2], ".mp3", 1_000_000),
50,
AutoDownloadPrefs{},
)
if !AutoPickable(req, []Candidate{best, weak}) {
if !AutoPickable(dl, []Candidate{best, weak}, AutoDownloadPrefs{}) {
t.Errorf(
"want auto-pickable: match %f quality %f lead %f",
best.Match.Overall, best.Quality.Overall, best.Score-weak.Score,
@@ -216,7 +217,7 @@ func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
twin := best
twin.ID = "twin"
if AutoPickable(req, []Candidate{best, twin}) {
if AutoPickable(dl, []Candidate{best, twin}, AutoDownloadPrefs{}) {
t.Error("identical candidates must not auto-pick")
}
})
@@ -224,9 +225,9 @@ func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
t.Run("free text is never auto-pickable", func(t *testing.T) {
t.Parallel()
free := Request{Artist: "Radiohead", Album: "OK Computer"}
free := Download{Artist: "Radiohead", Album: "OK Computer"}
if AutoPickable(free, []Candidate{best}) {
if AutoPickable(free, []Candidate{best}, AutoDownloadPrefs{}) {
t.Error("unanchored request must not auto-pick")
}
})
@@ -234,7 +235,7 @@ func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
t.Run("empty list is not", func(t *testing.T) {
t.Parallel()
if AutoPickable(req, nil) {
if AutoPickable(dl, nil, AutoDownloadPrefs{}) {
t.Error("empty candidate list must not auto-pick")
}
})
@@ -276,7 +277,7 @@ func TestCompleteness(t *testing.T) {
func TestProviderPriorityBreaksTies(t *testing.T) {
t.Parallel()
req := okComputer()
dl := okComputer()
a := candidateFor("a", allTitles(), ".flac", 30_000_000)
a.ProviderID = 1
@@ -292,9 +293,146 @@ func TestProviderPriorityBreaksTies(t *testing.T) {
return 10
}
ranked := Rank(req, []Candidate{a, b}, priority)
ranked := Rank(dl, []Candidate{a, b}, priority, AutoDownloadPrefs{})
if ranked[0].ID != "b" {
t.Errorf("winner = %s, want b (higher provider priority)", ranked[0].ID)
}
}
const mb = 1 << 20
func TestAutoDownloadPrefsEligible(t *testing.T) {
t.Parallel()
flacCandidate := candidateFor("c", allTitles(), ".flac", 30_000_000)
flacCandidate.Files = AnnotateFiles(flacCandidate.Files)
flacCandidate.TotalSize = 300 * mb
mp3Candidate := candidateFor("c", allTitles(), ".mp3", 3_000_000)
mp3Candidate.Files = AnnotateFiles(mp3Candidate.Files)
mp3Candidate.TotalSize = 30 * mb
tests := []struct {
name string
prefs AutoDownloadPrefs
c Candidate
want bool
}{
{"zero value is permissive", AutoDownloadPrefs{}, flacCandidate, true},
{
"within min/max window",
AutoDownloadPrefs{MinSizeMB: 100, MaxSizeMB: 500},
flacCandidate, true,
},
{
"below minimum",
AutoDownloadPrefs{MinSizeMB: 400},
flacCandidate, false,
},
{
"above maximum",
AutoDownloadPrefs{MaxSizeMB: 200},
flacCandidate, false,
},
{
"allowed format passes",
AutoDownloadPrefs{AllowedFormats: []Format{FormatFLAC}},
flacCandidate, true,
},
{
"disallowed format rejected",
AutoDownloadPrefs{AllowedFormats: []Format{FormatFLAC}},
mp3Candidate, false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.prefs.eligible(tt.c); got != tt.want {
t.Errorf("eligible() = %v, want %v", got, tt.want)
}
})
}
}
func TestAutoDownloadPrefsFilter(t *testing.T) {
t.Parallel()
small := candidateFor("small", allTitles(), ".flac", 10_000_000)
small.TotalSize = 50 * mb
big := candidateFor("big", allTitles(), ".flac", 30_000_000)
big.TotalSize = 500 * mb
prefs := AutoDownloadPrefs{MinSizeMB: 100, MaxSizeMB: 600}
filtered := prefs.filter([]Candidate{small, big})
if len(filtered) != 1 || filtered[0].ID != "big" {
t.Errorf("filter() = %v, want only the in-window candidate", filtered)
}
}
func TestAutoDownloadPrefsSizeFit(t *testing.T) {
t.Parallel()
const neutral = 0.5
tests := []struct {
name string
prefs AutoDownloadPrefs
totalSize int64
want float64
}{
{"no preference is neutral", AutoDownloadPrefs{}, 300 * mb, neutral},
{
"exact match scores 1",
AutoDownloadPrefs{PreferredSizeMB: 300},
300 * mb, 1.0,
},
{
"double the preferred size scores 0",
AutoDownloadPrefs{PreferredSizeMB: 300},
600 * mb, 0.0,
},
{
"half the preferred size scores 0",
AutoDownloadPrefs{PreferredSizeMB: 300},
150 * mb, 0.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.prefs.sizeFit(tt.totalSize); got != tt.want {
t.Errorf("sizeFit(%d) = %f, want %f", tt.totalSize, got, tt.want)
}
})
}
}
// An otherwise-perfect candidate must not auto-pick when it falls
// outside the configured size guard: the guardrail applies before the
// match/quality/lead checks, not as one more input averaged into them.
func TestAutoPickableRejectsCandidateOutsideSizeGuard(t *testing.T) {
t.Parallel()
dl := okComputer()
best := Score(dl, candidateFor("a", allTitles(), ".flac", 30_000_000), 50, AutoDownloadPrefs{})
best.TotalSize = 500 * mb
if !AutoPickable(dl, []Candidate{best}, AutoDownloadPrefs{}) {
t.Fatal("expected this candidate to be auto-pickable with no guardrails")
}
tight := AutoDownloadPrefs{MinSizeMB: 10, MaxSizeMB: 100}
if AutoPickable(dl, []Candidate{best}, tight) {
t.Error("candidate outside the size guard must not auto-pick")
}
}
+96 -96
View File
@@ -11,10 +11,10 @@ import (
"time"
)
// The reconciler is the only thing that turns wants into downloads.
// The reconciler is the only thing that turns requests into downloads.
//
// It runs on a slow loop rather than reacting to events, because
// everything it cares about changes slowly: a release the user wants
// everything it cares about changes slowly: a release the user requests
// appears on a source days or weeks after they asked, an artist puts
// out an album once a year, and a library gains files by scan rather
// than by notification. A loop that wakes a few times a day is
@@ -24,17 +24,17 @@ import (
//
// Each pass does four things, in this order and for this reason:
//
// 1. Expand artist subscriptions into per-album wants, so step 2 sees
// 1. Expand artist subscriptions into per-album requests, so step 2 sees
// them this pass rather than next.
// 2. Retire wants the library already owns — including ones the user
// 2. Retire requests the library already owns — including ones the user
// satisfied by other means, which is why ownership is checked
// rather than assumed from our own downloads.
// 3. Push the list to clients that keep their own (Lidarr), so the
// user's intent is expressed in both places.
// 4. Attempt a bounded batch of due wants.
// 4. Attempt a bounded batch of due requests.
//
// Nothing here fails a want. A want that cannot be found gets an
// attempt recorded and a longer backoff, and stays exactly as wanted as
// Nothing here fails a request. A request that cannot be found gets an
// attempt recorded and a longer backoff, and stays exactly as requested as
// it was.
// CatalogPort is what the reconciler needs to know about the world of
@@ -51,8 +51,8 @@ type CatalogPort interface {
) ([]CatalogItem, error)
// Tracklist resolves a release group or release to the tracks it
// should contain. This is what makes a want's download safe to
// complete unattended, so a want with no tracklist is never
// should contain. This is what makes a request's download safe to
// complete unattended, so a request with no tracklist is never
// auto-grabbed.
Tracklist(
ctx context.Context,
@@ -64,8 +64,8 @@ type CatalogPort interface {
// names.
Owns(ctx context.Context, entity Entity, mbid string) (bool, error)
// Describe fills in display text for a want the user added by MBID
// alone. Best-effort: an unknown MBID returns false and the want
// Describe fills in display text for a request the user added by MBID
// alone. Best-effort: an unknown MBID returns false and the request
// is still perfectly valid.
Describe(
ctx context.Context,
@@ -87,7 +87,7 @@ type CatalogItem struct {
PrimaryType string
// SecondaryTypes carries "Compilation", "Live", "Remix" and
// friends. Their presence is what an artist want's default scope
// friends. Their presence is what an artist request's default scope
// filters out.
SecondaryTypes []string
@@ -100,25 +100,25 @@ type CatalogItem struct {
// Reconciler defaults.
const (
// defaultReconcileInterval is how often the wanted list is worked.
// defaultReconcileInterval is how often the request list is worked.
// Four times a day is far more often than new music appears and far
// less often than any provider would object to.
defaultReconcileInterval = 6 * time.Hour
// startupDelay lets the app finish starting — library scan, explore
// index, provider construction — before the first pass. A wanted
// index, provider construction — before the first pass. A requested
// list worked against an index that has not loaded yet would record
// a pile of pointless attempts.
startupDelay = 3 * time.Minute
// maxExpandPerArtist bounds how many child wants one artist
// maxExpandPerArtist bounds how many child requests one artist
// subscription creates in a single pass, so switching an artist to
// full-discography scope does not enqueue four hundred albums at
// once. The remainder is picked up next pass.
maxExpandPerArtist = 40
)
// Reconciler works the wanted list.
// Reconciler works the request list.
type Reconciler struct {
logger *slog.Logger
store *Store
@@ -143,12 +143,12 @@ type Reconciler struct {
stop chan struct{}
// runMu serializes passes: two reconcilers racing would search for
// the same want twice.
// the same request twice.
runMu sync.Mutex
}
// NewReconciler builds a reconciler. catalog may be nil, in which case
// the wanted list still stores and lists wants but never acts on them —
// the request list still stores and lists requests but never acts on them —
// which is the right behaviour when the explore index is unavailable.
func NewReconciler(
logger *slog.Logger,
@@ -176,7 +176,7 @@ func (r *Reconciler) SetInterval(d time.Duration) {
}
}
// SetBatch overrides how many wants one pass attempts.
// SetBatch overrides how many requests one pass attempts.
func (r *Reconciler) SetBatch(n int) {
if n > 0 {
r.batch = n
@@ -200,7 +200,7 @@ func (r *Reconciler) Stop() {
}
// Trigger asks for a pass as soon as possible without blocking the
// caller. Used when the user adds a want and expects something to
// caller. Used when the user adds a request and expects something to
// happen.
func (r *Reconciler) Trigger() {
select {
@@ -229,27 +229,27 @@ func (r *Reconciler) loop(ctx context.Context) {
}
if _, err := r.RunOnce(ctx); err != nil {
r.logger.Warn("wanted list reconcile failed", "error", err)
r.logger.Warn("request list reconcile failed", "error", err)
}
}
}
// Summary reports what a pass did, for logging and for the UI.
type Summary struct {
// Expanded is how many child wants artist subscriptions produced.
// Expanded is how many child requests artist subscriptions produced.
Expanded int `json:"expanded"`
// Satisfied is how many wants the library turned out to own.
// Satisfied is how many requests the library turned out to own.
Satisfied int `json:"satisfied"`
// Attempted is how many wants were searched for.
// Attempted is how many requests were searched for.
Attempted int `json:"attempted"`
// Started is how many of those found a clear enough winner to
// download unattended.
Started int `json:"started"`
// Synced is how many wants were pushed to an external list.
// Synced is how many requests were pushed to an external list.
Synced int `json:"synced"`
}
@@ -259,7 +259,7 @@ func (s Summary) changed() bool {
return s.Expanded > 0 || s.Satisfied > 0 || s.Started > 0
}
// RunOnce works the wanted list once. It is safe to call directly, and
// RunOnce works the request list once. It is safe to call directly, and
// the "search now" button does.
func (r *Reconciler) RunOnce(ctx context.Context) (Summary, error) {
r.runMu.Lock()
@@ -296,7 +296,7 @@ func (r *Reconciler) RunOnce(ctx context.Context) (Summary, error) {
summary.Started = started
r.logger.Info(
"reconciled wanted list",
"reconciled request list",
"expanded", summary.Expanded,
"satisfied", summary.Satisfied,
"attempted", summary.Attempted,
@@ -315,15 +315,15 @@ func (r *Reconciler) RunOnce(ctx context.Context) (Summary, error) {
// Artist expansion
// ---------------------------------------------------------------------------
// expandArtists turns artist subscriptions into per-album wants.
// expandArtists turns artist subscriptions into per-album requests.
//
// The expansion is idempotent: child wants are upserted on (mbid,
// The expansion is idempotent: child requests are upserted on (mbid,
// library), so re-running adds only what is genuinely new. That is
// what makes an artist want a standing subscription rather than a
// what makes an artist request a standing subscription rather than a
// one-time queue-filling operation — an album released next year gets
// picked up by the same code path that ran today.
func (r *Reconciler) expandArtists(ctx context.Context) (int, error) {
artists, err := r.store.ListArtistWants(ctx)
artists, err := r.store.ListActiveRequests(ctx)
if err != nil {
return 0, err
}
@@ -336,7 +336,7 @@ func (r *Reconciler) expandArtists(ctx context.Context) (int, error) {
// One artist whose discography will not resolve must not
// stop the rest of the list.
r.logger.Warn(
"could not expand artist want",
"could not expand artist request",
"artist", artist.Label(),
"mbid", artist.MBID,
"error", err,
@@ -352,7 +352,7 @@ func (r *Reconciler) expandArtists(ctx context.Context) (int, error) {
}
// expandArtist expands one subscription.
func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error) {
func (r *Reconciler) expandArtist(ctx context.Context, artist Request) (int, error) {
groups, err := r.catalog.ReleaseGroupsForArtist(ctx, artist.MBID)
if err != nil {
return 0, err
@@ -365,16 +365,16 @@ func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error)
break
}
if !wantsReleaseGroup(artist, rg) {
if !requestsReleaseGroup(artist, rg) {
continue
}
// Existence is checked before inserting rather than relying on
// the upsert, because "how many albums are new this pass" is
// the number the UI reports and an upsert cannot tell an insert
// from a no-op. It also means a want the user pinned by hand
// from a no-op. It also means a request the user pinned by hand
// is never quietly reparented under the artist.
if _, exists, err := r.store.FindWant(
if _, exists, err := r.store.FindRequest(
ctx, rg.MBID, artist.LibraryID,
); err != nil || exists {
continue
@@ -385,7 +385,7 @@ func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error)
credit = artist.Artist
}
if _, err := r.store.AddWant(ctx, Want{
if _, err := r.store.AddRequest(ctx, Request{
MBID: rg.MBID,
Entity: EntityReleaseGroup,
LibraryID: artist.LibraryID,
@@ -394,7 +394,7 @@ func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error)
ParentID: artist.ID,
}); err != nil {
r.logger.Warn(
"could not add derived want",
"could not add derived request",
"release_group", rg.MBID,
"error", err,
)
@@ -408,9 +408,9 @@ func (r *Reconciler) expandArtist(ctx context.Context, artist Want) (int, error)
return created, nil
}
// wantsReleaseGroup applies an artist subscription's filters to one
// requestsReleaseGroup applies an artist subscription's filters to one
// release group.
func wantsReleaseGroup(artist Want, rg CatalogItem) bool {
func requestsReleaseGroup(artist Request, rg CatalogItem) bool {
if rg.MBID == "" || rg.InLibrary {
return false
}
@@ -457,30 +457,30 @@ func releaseDateAfter(date string, since time.Time) bool {
// Retiring what the library already has
// ---------------------------------------------------------------------------
// retireOwned satisfies wants the library turns out to own.
// retireOwned satisfies requests the library turns out to own.
//
// Ownership is asked of the library rather than inferred from our own
// completed downloads on purpose: the user may have bought the album,
// ripped their CD, or copied it in from another machine, and a wanted
// ripped their CD, or copied it in from another machine, and a requested
// list that keeps hunting for music already sitting on disk is worse
// than no wanted list at all.
// than no request list at all.
func (r *Reconciler) retireOwned(ctx context.Context) (int, error) {
wants, err := r.store.ListWants(ctx)
requests, err := r.store.ListRequests(ctx)
if err != nil {
return 0, err
}
satisfied := 0
for _, w := range wants {
if w.State != WantStateWanted || w.Entity.Expands() {
for _, req := range requests {
if req.State != RequestStateWanted || req.Entity.Expands() {
continue
}
owned, err := r.catalog.Owns(ctx, w.Entity, w.MBID)
owned, err := r.catalog.Owns(ctx, req.Entity, req.MBID)
if err != nil {
r.logger.Debug(
"ownership check failed", "want", w.MBID, "error", err,
"ownership check failed", "request", req.MBID, "error", err,
)
continue
@@ -490,8 +490,8 @@ func (r *Reconciler) retireOwned(ctx context.Context) (int, error) {
continue
}
if err := r.store.SatisfyWant(ctx, w.ID); err != nil {
r.logger.Warn("could not satisfy want", "want", w.ID, "error", err)
if err := r.store.SatisfyRequest(ctx, req.ID); err != nil {
r.logger.Warn("could not satisfy request", "request", req.ID, "error", err)
continue
}
@@ -506,15 +506,15 @@ func (r *Reconciler) retireOwned(ctx context.Context) (int, error) {
// Attempting downloads
// ---------------------------------------------------------------------------
// attemptDue searches for a bounded batch of due wants and grabs the
// attemptDue searches for a bounded batch of due requests and grabs the
// ones with a clear winner.
func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, err error) {
due, err := r.store.ListDueWants(ctx, r.batch)
due, err := r.store.ListDueRequests(ctx, r.batch)
if err != nil {
return 0, 0, err
}
for _, w := range due {
for _, req := range due {
select {
case <-ctx.Done():
return attempted, started, nil
@@ -523,7 +523,7 @@ func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, er
attempted++
ok, reason := r.attempt(ctx, w)
ok, reason := r.attempt(ctx, req)
if ok {
started++
@@ -531,10 +531,10 @@ func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, er
}
if err := r.store.RecordAttempt(
ctx, w.ID, w.Attempts, reason,
ctx, req.ID, req.Attempts, reason,
); err != nil {
r.logger.Warn(
"could not record want attempt", "want", w.ID, "error", err,
"could not record request attempt", "request", req.ID, "error", err,
)
}
}
@@ -542,64 +542,64 @@ func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, er
return attempted, started, nil
}
// tracklistFor resolves what a want should contain, which is the
// tracklistFor resolves what a request should contain, which is the
// evidence an unattended download is checked against.
//
// A track want is its own tracklist: one entry, built from the title
// the want already carries. That single expected title is what lets
// A track request is its own tracklist: one entry, built from the title
// the request already carries. That single expected title is what lets
// filename matching score a track download at all — without it a
// request for one song would be scored as an album with no tracks and
// could never clear the auto-pick bar.
func (r *Reconciler) tracklistFor(
ctx context.Context,
w Want,
req Request,
) ([]ExpectedTrack, error) {
if w.Entity != EntityRecording {
return r.catalog.Tracklist(ctx, w.Entity, w.MBID)
if req.Entity != EntityRecording {
return r.catalog.Tracklist(ctx, req.Entity, req.MBID)
}
if w.Title == "" {
if req.Title == "" {
return nil, nil
}
return []ExpectedTrack{{
Position: 1,
Title: w.Title,
Artist: w.Artist,
Title: req.Title,
Artist: req.Artist,
}}, nil
}
// attempt tries one want. It returns false with a human-readable
// attempt tries one request. It returns false with a human-readable
// reason rather than an error, because none of the ways this does not
// work out are failures: no providers configured yet, nothing on any
// source, or nothing good enough to take without asking are all just
// "not today".
func (r *Reconciler) attempt(ctx context.Context, w Want) (bool, string) {
expected, err := r.tracklistFor(ctx, w)
func (r *Reconciler) attempt(ctx context.Context, req Request) (bool, string) {
expected, err := r.tracklistFor(ctx, req)
if err != nil || len(expected) == 0 {
// Without a tracklist an unattended grab has nothing to verify
// itself against, so this want waits rather than guessing. The
// itself against, so this request waits rather than guessing. The
// tracklist usually arrives on its own once the explore index
// fetches the release.
return false, "waiting for the tracklist to resolve"
}
req := w.ToRequest(newID())
req.Expected = expected
dl := req.ToDownload(newID())
dl.Expected = expected
if req.Artist == "" || req.Album == "" {
if item, ok := r.catalog.Describe(ctx, w.Entity, w.MBID); ok {
if req.Artist == "" {
req.Artist = item.Artist
if dl.Artist == "" || dl.Album == "" {
if item, ok := r.catalog.Describe(ctx, req.Entity, req.MBID); ok {
if dl.Artist == "" {
dl.Artist = item.Artist
}
if req.Album == "" {
req.Album = item.Title
if dl.Album == "" {
dl.Album = item.Title
}
}
}
started, reason, err := r.manager.Attempt(ctx, req)
started, reason, err := r.manager.Attempt(ctx, dl)
if err != nil {
if errors.Is(err, ErrNoProviders) {
return false, "no download clients are enabled"
@@ -619,7 +619,7 @@ func (r *Reconciler) attempt(ctx context.Context, w Want) (bool, string) {
// External list sync
// ---------------------------------------------------------------------------
// syncExternalLists pushes wants to providers that keep a persistent
// syncExternalLists pushes requests to providers that keep a persistent
// list of their own.
//
// The sync is one-directional by design. Two systems that both accept
@@ -634,21 +634,21 @@ func (r *Reconciler) syncExternalLists(ctx context.Context) int {
return 0
}
wants, err := r.store.ListWants(ctx)
requests, err := r.store.ListRequests(ctx)
if err != nil {
r.logger.Warn("could not list wants for sync", "error", err)
r.logger.Warn("could not list requests for sync", "error", err)
return 0
}
synced := 0
for _, w := range wants {
if w.State != WantStateWanted {
for _, req := range requests {
if req.State != RequestStateWanted {
continue
}
external := w.ExternalIDs
external := req.ExternalIDs
if external == nil {
external = map[string]string{}
}
@@ -661,11 +661,11 @@ func (r *Reconciler) syncExternalLists(ctx context.Context) int {
continue
}
externalID, err := l.PushWant(ctx, w)
externalID, err := l.PushRequest(ctx, req)
if err != nil {
r.logger.Debug(
"could not push want to external list",
"want", w.MBID,
"could not push request to external list",
"request", req.MBID,
"provider", id,
"error", err,
)
@@ -686,9 +686,9 @@ func (r *Reconciler) syncExternalLists(ctx context.Context) int {
continue
}
if err := r.store.SetWantExternalIDs(ctx, w.ID, external); err != nil {
if err := r.store.SetRequestExternalIDs(ctx, req.ID, external); err != nil {
r.logger.Warn(
"could not record external want ids", "want", w.ID, "error", err,
"could not record external request ids", "request", req.ID, "error", err,
)
}
}
@@ -696,7 +696,7 @@ func (r *Reconciler) syncExternalLists(ctx context.Context) int {
return synced
}
// ImportExternal pulls an external manager's own list into the wanted
// ImportExternal pulls an external manager's own list into the requested
// list. This is the one place data flows the other way, and it is a
// deliberate user action ("import my monitored Lidarr artists") rather
// than part of the loop, because silently adopting whatever another
@@ -715,19 +715,19 @@ func (r *Reconciler) ImportExternal(
)
}
external, err := l.ListWants(ctx)
external, err := l.ListRequests(ctx)
if err != nil {
return 0, fmt.Errorf("list external wants: %w", err)
return 0, fmt.Errorf("list external requests: %w", err)
}
imported := 0
for _, w := range external {
w.LibraryID = libraryID
for _, req := range external {
req.LibraryID = libraryID
if _, err := r.store.AddWant(ctx, w); err != nil {
if _, err := r.store.AddRequest(ctx, req); err != nil {
r.logger.Warn(
"could not import external want", "mbid", w.MBID, "error", err,
"could not import external request", "mbid", req.MBID, "error", err,
)
continue
+56 -56
View File
@@ -111,14 +111,14 @@ func TestExpandArtistIsIdempotent(t *testing.T) {
{MBID: "rg-2", Title: "Second", FirstReleaseDate: "2030-06-01"},
}
if _, err := f.store.AddWant(ctx, Want{
if _, err := f.store.AddRequest(ctx, Request{
MBID: "artist-1",
Entity: EntityArtist,
LibraryID: 1,
Artist: "Radiohead",
Scope: ScopeAll,
}); err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
first, err := f.reconciler.expandArtists(ctx)
@@ -127,7 +127,7 @@ func TestExpandArtistIsIdempotent(t *testing.T) {
}
if first != 2 {
t.Fatalf("first pass created %d wants, want 2", first)
t.Fatalf("first pass created %d requests, want 2", first)
}
second, err := f.reconciler.expandArtists(ctx)
@@ -136,7 +136,7 @@ func TestExpandArtistIsIdempotent(t *testing.T) {
}
if second != 0 {
t.Errorf("second pass created %d wants, want 0", second)
t.Errorf("second pass created %d requests, want 0", second)
}
// A new album appearing later is picked up by the same pass.
@@ -153,7 +153,7 @@ func TestExpandArtistIsIdempotent(t *testing.T) {
}
if third != 1 {
t.Errorf("third pass created %d wants, want 1", third)
t.Errorf("third pass created %d requests, want 1", third)
}
}
@@ -170,32 +170,32 @@ func TestExpandArtistFutureScopeSkipsBackCatalogue(t *testing.T) {
{MBID: "rg-new", Title: "New", FirstReleaseDate: "2099-01-01"},
}
if _, err := f.store.AddWant(ctx, Want{
if _, err := f.store.AddRequest(ctx, Request{
MBID: "artist-1",
Entity: EntityArtist,
LibraryID: 1,
Scope: ScopeFuture,
}); err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
if _, err := f.reconciler.expandArtists(ctx); err != nil {
t.Fatalf("expandArtists: %v", err)
}
wants, err := f.store.ListWants(ctx)
requests, err := f.store.ListRequests(ctx)
if err != nil {
t.Fatalf("ListWants: %v", err)
t.Fatalf("ListDownloads: %v", err)
}
for _, w := range wants {
if w.MBID == "rg-old" {
for _, req := range requests {
if req.MBID == "rg-old" {
t.Error("future scope queued a back-catalogue album")
}
}
if len(wants) != 2 {
t.Errorf("got %d wants (artist + new album), want 2", len(wants))
if len(requests) != 2 {
t.Errorf("got %d requests (artist + new album), want 2", len(requests))
}
}
@@ -209,12 +209,12 @@ func TestExpandArtistToleratesCatalogFailure(t *testing.T) {
f.catalog.discographyErr = errors.New("index not ready") //nolint:err113 // test
if _, err := f.store.AddWant(ctx, Want{
if _, err := f.store.AddRequest(ctx, Request{
MBID: "artist-1",
Entity: EntityArtist,
LibraryID: 1,
}); err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
created, err := f.reconciler.expandArtists(ctx)
@@ -223,24 +223,24 @@ func TestExpandArtistToleratesCatalogFailure(t *testing.T) {
}
if created != 0 {
t.Errorf("created %d wants from a failing catalog, want 0", created)
t.Errorf("created %d requests from a failing catalog, want 0", created)
}
}
// Something the library already owns is retired, however it got there.
func TestRetireOwnedSatisfiesWants(t *testing.T) {
func TestRetireOwnedSatisfiesRequests(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
id, err := f.store.AddWant(ctx, Want{
id, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
})
if err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
f.catalog.owned["rg-1"] = true
@@ -251,16 +251,16 @@ func TestRetireOwnedSatisfiesWants(t *testing.T) {
}
if n != 1 {
t.Fatalf("retired %d wants, want 1", n)
t.Fatalf("retired %d requests, want 1", n)
}
w, err := f.store.GetWant(ctx, id)
req, err := f.store.GetRequest(ctx, id)
if err != nil {
t.Fatalf("GetWant: %v", err)
t.Fatalf("GetRequest: %v", err)
}
if w.State != WantStateSatisfied {
t.Errorf("state = %q, want satisfied", w.State)
if req.State != RequestStateSatisfied {
t.Errorf("state = %q, want satisfied", req.State)
}
}
@@ -275,7 +275,7 @@ func TestReconcileDownloadsAndSatisfies(t *testing.T) {
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
id, err := f.store.AddWant(ctx, Want{
id, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
@@ -283,10 +283,10 @@ func TestReconcileDownloadsAndSatisfies(t *testing.T) {
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
f.catalog.tracklists["rg-1"] = fourTrackRequest().Expected
f.catalog.tracklists["rg-1"] = fourTrackDownload().Expected
summary, err := f.reconciler.RunOnce(ctx)
if err != nil {
@@ -301,9 +301,9 @@ func TestReconcileDownloadsAndSatisfies(t *testing.T) {
}
waitFor(t, func() bool {
w, err := f.store.GetWant(ctx, id)
req, err := f.store.GetRequest(ctx, id)
return err == nil && w.State == WantStateSatisfied
return err == nil && req.State == RequestStateSatisfied
}, "want was never satisfied after its download completed")
if provider.GrabCalls != 1 {
@@ -313,7 +313,7 @@ func TestReconcileDownloadsAndSatisfies(t *testing.T) {
// Nothing good enough is not a failure. The want stays wanted, gains
// an attempt and a reason, and leaves no request row behind.
func TestReconcileKeepsWantingWhenNothingIsGoodEnough(t *testing.T) {
func TestReconcileKeepsRequestingWhenNothingIsGoodEnough(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
@@ -328,7 +328,7 @@ func TestReconcileKeepsWantingWhenNothingIsGoodEnough(t *testing.T) {
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
id, err := f.store.AddWant(ctx, Want{
id, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
@@ -336,10 +336,10 @@ func TestReconcileKeepsWantingWhenNothingIsGoodEnough(t *testing.T) {
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
f.catalog.tracklists["rg-1"] = fourTrackRequest().Expected
f.catalog.tracklists["rg-1"] = fourTrackDownload().Expected
summary, err := f.reconciler.RunOnce(ctx)
if err != nil {
@@ -350,32 +350,32 @@ func TestReconcileKeepsWantingWhenNothingIsGoodEnough(t *testing.T) {
t.Fatalf("started %d downloads, want 0", summary.Started)
}
w, err := f.store.GetWant(ctx, id)
req, err := f.store.GetRequest(ctx, id)
if err != nil {
t.Fatalf("GetWant: %v", err)
t.Fatalf("GetRequest: %v", err)
}
if w.State != WantStateWanted {
t.Errorf("state = %q, want it still wanted", w.State)
if req.State != RequestStateWanted {
t.Errorf("state = %q, want it still wanted", req.State)
}
if w.Attempts != 1 {
t.Errorf("attempts = %d, want 1", w.Attempts)
if req.Attempts != 1 {
t.Errorf("attempts = %d, want 1", req.Attempts)
}
if w.LastError == "" {
if req.LastError == "" {
t.Error("no reason was recorded for the user")
}
if !w.NextTryAt.After(time.Now()) {
t.Errorf("next try at %v, want it in the future", w.NextTryAt)
if !req.NextTryAt.After(time.Now()) {
t.Errorf("next try at %v, want it in the future", req.NextTryAt)
}
// The whole point of Attempt over Start: an unsuccessful pass
// leaves no request row to clutter the downloads list.
requests, err := f.store.ListRequests(ctx, 50)
requests, err := f.store.ListDownloads(ctx, 50)
if err != nil {
t.Fatalf("ListRequests: %v", err)
t.Fatalf("ListDownloads: %v", err)
}
if len(requests) != 0 {
@@ -393,14 +393,14 @@ func TestReconcileWaitsWithoutTracklist(t *testing.T) {
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
if _, err := f.store.AddWant(ctx, Want{
if _, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
Artist: "Radiohead",
Title: "OK Computer",
}); err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
summary, err := f.reconciler.RunOnce(ctx)
@@ -421,27 +421,27 @@ func TestReconcileWaitsWithoutTracklist(t *testing.T) {
}
// Artist subscriptions are never attempted as downloads: they expand.
func TestArtistWantsAreNeverDue(t *testing.T) {
func TestArtistRequestsAreNeverDue(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
if _, err := f.store.AddWant(ctx, Want{
if _, err := f.store.AddRequest(ctx, Request{
MBID: "artist-1",
Entity: EntityArtist,
LibraryID: 1,
}); err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
due, err := f.store.ListDueWants(ctx, 10)
due, err := f.store.ListDueRequests(ctx, 10)
if err != nil {
t.Fatalf("ListDueWants: %v", err)
t.Fatalf("ListDueRequests: %v", err)
}
if len(due) != 0 {
t.Errorf("got %d due wants, want 0 — artists expand, not download", len(due))
t.Errorf("got %d due requests, want 0 — artists expand, not download", len(due))
}
}
@@ -456,16 +456,16 @@ func TestReconcileRespectsBatchSize(t *testing.T) {
f.reconciler.SetBatch(2)
for _, mbid := range []string{"rg-1", "rg-2", "rg-3", "rg-4"} {
if _, err := f.store.AddWant(ctx, Want{
if _, err := f.store.AddRequest(ctx, Request{
MBID: mbid,
Entity: EntityReleaseGroup,
LibraryID: 1,
Title: "Album " + mbid,
}); err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
f.catalog.tracklists[mbid] = fourTrackRequest().Expected
f.catalog.tracklists[mbid] = fourTrackDownload().Expected
}
summary, err := f.reconciler.RunOnce(ctx)
@@ -474,7 +474,7 @@ func TestReconcileRespectsBatchSize(t *testing.T) {
}
if summary.Attempted != 2 {
t.Errorf("attempted %d wants, want 2 (the batch size)", summary.Attempted)
t.Errorf("attempted %d requests, want 2 (the batch size)", summary.Attempted)
}
}
+240
View File
@@ -0,0 +1,240 @@
package download
import (
"math"
"math/rand/v2"
"time"
)
// A Request is a persistent "I want this", stored as a MusicBrainz ID
// and almost nothing else.
//
// The distinction from Download is the whole point of this file. A
// Download is one attempt: it searches, it grabs, it succeeds or fails,
// and then it is history. A Request outlives every attempt made on its
// behalf. Nothing being findable today is the normal case for obscure
// music, and the correct response is to try again next week, not to
// show the user a failed row they have to remember to retry.
//
// Because a Request is only an MBID, it stays true when everything
// around it changes: the explore index is rebuilt, a provider is
// swapped out, the release the user originally saw is superseded by a
// remaster. The display fields are a cache for the list view and are
// never consulted for matching.
// Entity says what a request's MBID names, and is the only type
// distinction the durable request list makes.
type Entity string
// Request entity types.
const (
// EntityArtist is a subscription rather than a thing to fetch: it
// is never satisfied, and each reconcile expands the artist's
// discography into child requests.
EntityArtist Entity = "artist"
// EntityReleaseGroup is an album in the abstract — any release of
// it satisfies the request, which is what a user means by "I want
// this album".
EntityReleaseGroup Entity = "release-group"
// EntityRelease is one specific edition, used when the user picked
// a particular pressing.
EntityRelease Entity = "release"
// EntityRecording is a single track.
EntityRecording Entity = "recording"
)
// Valid reports whether e is a known entity type.
func (e Entity) Valid() bool {
switch e {
case EntityArtist, EntityReleaseGroup, EntityRelease, EntityRecording:
return true
default:
return false
}
}
// Expands reports whether this entity produces child requests rather
// than being downloaded directly.
func (e Entity) Expands() bool {
return e == EntityArtist
}
// RequestState is where a request sits. There is deliberately no
// "failed": an attempt can fail, a request cannot. A request that has
// tried and not found anything is still wanted, with attempts and
// last_error recording why it is taking a while.
type RequestState string
// Request states.
const (
// RequestStateWanted is the active state: due for another attempt
// when its backoff elapses.
RequestStateWanted RequestState = "wanted"
// RequestStateSatisfied means the library owns it. How it got
// there — downloaded here, ripped, bought elsewhere — does not
// matter.
RequestStateSatisfied RequestState = "satisfied"
// RequestStatePaused is the user saying "keep this on the list but
// stop trying".
RequestStatePaused RequestState = "paused"
)
// RequestScope applies to artist requests only.
type RequestScope string
// Artist request scopes.
const (
// ScopeFuture takes only releases first published after the artist
// was added. Default, because subscribing to an artist should not
// silently queue their entire back catalogue.
ScopeFuture RequestScope = "future"
// ScopeAll backfills the whole discography as well.
ScopeAll RequestScope = "all"
)
// Request is one row of the durable request list.
type Request struct {
ID int64 `json:"id"`
MBID string `json:"mbid"`
Entity Entity `json:"entity"`
LibraryID int64 `json:"libraryId"`
// Artist and Title are display cache only. Matching always uses
// the MBID.
Artist string `json:"artist"`
Title string `json:"title"`
Scope RequestScope `json:"scope"`
// Secondary includes compilations, live albums and remixes in an
// artist request's expansion.
Secondary bool `json:"secondary"`
State RequestState `json:"state"`
// ParentID is set on requests the reconciler derived from an artist
// subscription. A request the user pinned directly has none, so
// removing the artist leaves it alone.
ParentID int64 `json:"parentId,omitempty"`
Attempts int `json:"attempts"`
LastError string `json:"lastError,omitempty"`
LastTriedAt time.Time `json:"lastTriedAt,omitempty"`
NextTryAt time.Time `json:"nextTryAt,omitempty"`
// ExternalIDs maps provider row ID (as a string, because JSON
// object keys are strings) to that provider's own identifier for
// this request. Only set for providers that keep a persistent
// list of their own.
ExternalIDs map[string]string `json:"externalIds,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Anchored is always true for a request: it is an MBID by construction.
// The method exists so requests and downloads read the same at call
// sites.
func (r Request) Anchored() bool { return r.MBID != "" }
// Label is the request list's one-line description of a request.
func (r Request) Label() string {
switch {
case r.Artist != "" && r.Title != "":
return r.Artist + " — " + r.Title
case r.Title != "":
return r.Title
case r.Artist != "":
return r.Artist
default:
return string(r.Entity) + " " + r.MBID
}
}
// Retry backoff. A request that cannot be found is usually one that
// will not be findable for a while — a pre-release, something only
// ever on physical media, an artist no source indexes — so the
// schedule climbs fast and then sits at a weekly poll rather than
// hammering providers with the same fruitless search.
const (
// requestRetryBase is the delay after the first unsuccessful
// attempt.
requestRetryBase = 6 * time.Hour
// requestRetryMax caps the backoff. A weekly retry on a list of a
// few hundred requests is a handful of searches a day, which every
// provider tolerates.
requestRetryMax = 7 * 24 * time.Hour
// requestRetryJitter spreads retries so a list added in one sitting
// does not come due in one burst.
requestRetryJitter = 0.2
)
// nextRetry returns when a request with the given attempt count should
// be tried again: exponential from requestRetryBase, capped at
// requestRetryMax, jittered so a batch added together does not stay in
// lockstep forever.
func nextRetry(now time.Time, attempts int) time.Time {
if attempts < 1 {
attempts = 1
}
// Cap the exponent before shifting so a long-lived request cannot
// overflow the duration into something negative.
const maxExp = 16
exp := min(attempts-1, maxExp)
delay := float64(requestRetryBase) * math.Pow(2, float64(exp))
if delay > float64(requestRetryMax) {
delay = float64(requestRetryMax)
}
jitter := delay * requestRetryJitter * (rand.Float64()*2 - 1) //nolint:gosec // spreading retries, not a secret
return now.Add(time.Duration(delay + jitter))
}
// requestSource is the download source recorded for reconciler-raised
// downloads, so the downloads list can tell them apart from the ones a
// user started by hand.
const requestSource = "wanted"
// ToDownload builds the download that would satisfy this request.
// Expected is filled by the caller from the catalog, since resolving a
// tracklist is I/O and this is not.
func (r Request) ToDownload(id string) Download {
d := Download{
ID: id,
LibraryID: r.LibraryID,
Artist: r.Artist,
Album: r.Title,
RequestID: r.ID,
Source: requestSource,
}
switch r.Entity {
case EntityRelease:
d.ReleaseMBID = r.MBID
case EntityReleaseGroup:
d.ReleaseGroupMBID = r.MBID
case EntityRecording:
// A recording has no release anchor, so ranking has only the
// title to go on and auto-pick stays off. The MBID is still
// carried in RecordingMBID so a provider that can use it does.
d.RecordingMBID = r.MBID
case EntityArtist:
// Artist requests expand into children and are never turned
// into a download directly; this case exists so the switch is
// exhaustive rather than because it can happen.
}
return d
}
@@ -17,18 +17,18 @@ func TestNextRetryClimbsAndCaps(t *testing.T) {
attempts int
nominal time.Duration
}{
{attempts: 1, nominal: wantRetryBase},
{attempts: 2, nominal: 2 * wantRetryBase},
{attempts: 3, nominal: 4 * wantRetryBase},
{attempts: 20, nominal: wantRetryMax},
{attempts: 500, nominal: wantRetryMax},
{attempts: 1, nominal: requestRetryBase},
{attempts: 2, nominal: 2 * requestRetryBase},
{attempts: 3, nominal: 4 * requestRetryBase},
{attempts: 20, nominal: requestRetryMax},
{attempts: 500, nominal: requestRetryMax},
}
for _, tt := range tests {
got := nextRetry(now, tt.attempts).Sub(now)
lo := time.Duration(float64(tt.nominal) * (1 - wantRetryJitter))
hi := time.Duration(float64(tt.nominal) * (1 + wantRetryJitter))
lo := time.Duration(float64(tt.nominal) * (1 - requestRetryJitter))
hi := time.Duration(float64(tt.nominal) * (1 + requestRetryJitter))
if got < lo || got > hi {
t.Errorf(
@@ -83,14 +83,14 @@ func TestReleaseDateAfter(t *testing.T) {
}
}
func TestWantsReleaseGroupFilters(t *testing.T) {
func TestRequestsReleaseGroupFilters(t *testing.T) {
t.Parallel()
subscribed := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
future := Want{Scope: ScopeFuture, CreatedAt: subscribed}
all := Want{Scope: ScopeAll, CreatedAt: subscribed}
allSecondary := Want{
future := Request{Scope: ScopeFuture, CreatedAt: subscribed}
all := Request{Scope: ScopeAll, CreatedAt: subscribed}
allSecondary := Request{
Scope: ScopeAll, Secondary: true, CreatedAt: subscribed,
}
@@ -107,7 +107,7 @@ func TestWantsReleaseGroupFilters(t *testing.T) {
tests := []struct {
name string
artist Want
artist Request
rg CatalogItem
want bool
}{
@@ -131,54 +131,54 @@ func TestWantsReleaseGroupFilters(t *testing.T) {
}
for _, tt := range tests {
if got := wantsReleaseGroup(tt.artist, tt.rg); got != tt.want {
if got := requestsReleaseGroup(tt.artist, tt.rg); got != tt.want {
t.Errorf("%s: got %v, want %v", tt.name, got, tt.want)
}
}
}
func TestWantToRequestAnchors(t *testing.T) {
func TestRequestToDownloadAnchors(t *testing.T) {
t.Parallel()
tests := []struct {
entity Entity
check func(Request) string
check func(Download) string
}{
{
entity: EntityReleaseGroup,
check: func(r Request) string {
check: func(r Download) string {
return r.ReleaseGroupMBID
},
},
{
entity: EntityRelease,
check: func(r Request) string { return r.ReleaseMBID },
check: func(r Download) string { return r.ReleaseMBID },
},
{
entity: EntityRecording,
check: func(r Request) string { return r.RecordingMBID },
check: func(r Download) string { return r.RecordingMBID },
},
}
for _, tt := range tests {
w := Want{ID: 7, MBID: "mbid-x", Entity: tt.entity, LibraryID: 1}
req := Request{ID: 7, MBID: "mbid-x", Entity: tt.entity, LibraryID: 1}
req := w.ToRequest("req-1")
dl := req.ToDownload("dl-1")
if got := tt.check(req); got != "mbid-x" {
if got := tt.check(dl); got != "mbid-x" {
t.Errorf("%s: anchor = %q, want mbid-x", tt.entity, got)
}
if !req.Anchored() {
t.Errorf("%s: request is not anchored", tt.entity)
if !dl.Anchored() {
t.Errorf("%s: download is not anchored", tt.entity)
}
if req.WantID != 7 {
t.Errorf("%s: WantID = %d, want 7", tt.entity, req.WantID)
if dl.RequestID != 7 {
t.Errorf("%s: RequestID = %d, want 7", tt.entity, dl.RequestID)
}
if req.Source != wantSource {
t.Errorf("%s: Source = %q, want %q", tt.entity, req.Source, wantSource)
if dl.Source != requestSource {
t.Errorf("%s: Source = %q, want %q", tt.entity, dl.Source, requestSource)
}
}
}
@@ -189,7 +189,7 @@ func TestWantToRequestAnchors(t *testing.T) {
func TestAutoPickableRequiresTracklist(t *testing.T) {
t.Parallel()
req := Request{ReleaseGroupMBID: "rg-1", Artist: "A", Album: "B"}
dl := Download{ReleaseGroupMBID: "rg-1", Artist: "A", Album: "B"}
ranked := []Candidate{{
Match: MatchScore{Overall: 0.99, Anchored: true},
@@ -197,42 +197,42 @@ func TestAutoPickableRequiresTracklist(t *testing.T) {
Score: 0.95,
}}
if AutoPickable(req, ranked) {
t.Error("auto-picked a request with no expected tracklist")
if AutoPickable(dl, ranked, AutoDownloadPrefs{}) {
t.Error("auto-picked a download with no expected tracklist")
}
req.Expected = []ExpectedTrack{{Position: 1, Title: "T"}}
dl.Expected = []ExpectedTrack{{Position: 1, Title: "T"}}
if !AutoPickable(req, ranked) {
t.Error("did not auto-pick a well-anchored, well-matched request")
if !AutoPickable(dl, ranked, AutoDownloadPrefs{}) {
t.Error("did not auto-pick a well-anchored, well-matched download")
}
}
// The wanted list's identity is the MBID, so the same one arriving
// twice — in a different case, with whitespace — is one row.
func TestAddWantNormalizesAndDeduplicates(t *testing.T) {
func TestAddRequestNormalizesAndDeduplicates(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
ctx := context.Background()
first, err := f.store.AddWant(ctx, Want{
first, err := f.store.AddRequest(ctx, Request{
MBID: " ABC-123 ",
Entity: EntityReleaseGroup,
LibraryID: 1,
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
second, err := f.store.AddWant(ctx, Want{
second, err := f.store.AddRequest(ctx, Request{
MBID: "abc-123",
Entity: EntityReleaseGroup,
LibraryID: 1,
})
if err != nil {
t.Fatalf("AddWant again: %v", err)
t.Fatalf("AddRequest again: %v", err)
}
if first != second {
@@ -240,27 +240,27 @@ func TestAddWantNormalizesAndDeduplicates(t *testing.T) {
}
// Re-adding with no title must not wipe the one we have.
w, err := f.store.GetWant(ctx, first)
req, err := f.store.GetRequest(ctx, first)
if err != nil {
t.Fatalf("GetWant: %v", err)
t.Fatalf("GetRequest: %v", err)
}
if w.Title != "OK Computer" {
t.Errorf("title = %q, want it preserved", w.Title)
if req.Title != "OK Computer" {
t.Errorf("title = %q, want it preserved", req.Title)
}
if w.MBID != "abc-123" {
t.Errorf("mbid = %q, want normalized", w.MBID)
if req.MBID != "abc-123" {
t.Errorf("mbid = %q, want normalized", req.MBID)
}
}
func TestWantStoreLifecycle(t *testing.T) {
func TestRequestStoreLifecycle(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
ctx := context.Background()
id, err := f.store.AddWant(ctx, Want{
id, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
@@ -268,17 +268,17 @@ func TestWantStoreLifecycle(t *testing.T) {
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddWant: %v", err)
t.Fatalf("AddRequest: %v", err)
}
// A brand new want is due immediately.
due, err := f.store.ListDueWants(ctx, 10)
due, err := f.store.ListDueRequests(ctx, 10)
if err != nil {
t.Fatalf("ListDueWants: %v", err)
t.Fatalf("ListDueRequests: %v", err)
}
if len(due) != 1 {
t.Fatalf("got %d due wants, want 1", len(due))
t.Fatalf("got %d due requests, want 1", len(due))
}
// Recording an attempt pushes it out of the due set without
@@ -287,92 +287,92 @@ func TestWantStoreLifecycle(t *testing.T) {
t.Fatalf("RecordAttempt: %v", err)
}
due, err = f.store.ListDueWants(ctx, 10)
due, err = f.store.ListDueRequests(ctx, 10)
if err != nil {
t.Fatalf("ListDueWants after attempt: %v", err)
t.Fatalf("ListDueRequests after attempt: %v", err)
}
if len(due) != 0 {
t.Errorf("got %d due wants after an attempt, want 0", len(due))
t.Errorf("got %d due requests after an attempt, want 0", len(due))
}
w, err := f.store.GetWant(ctx, id)
req, err := f.store.GetRequest(ctx, id)
if err != nil {
t.Fatalf("GetWant: %v", err)
t.Fatalf("GetRequest: %v", err)
}
if w.State != WantStateWanted {
t.Errorf("state = %q, want it still wanted", w.State)
if req.State != RequestStateWanted {
t.Errorf("state = %q, want it still wanted", req.State)
}
if w.Attempts != 1 {
t.Errorf("attempts = %d, want 1", w.Attempts)
if req.Attempts != 1 {
t.Errorf("attempts = %d, want 1", req.Attempts)
}
if w.LastError == "" {
if req.LastError == "" {
t.Error("last error was not recorded")
}
if err := f.store.SatisfyWant(ctx, id); err != nil {
t.Fatalf("SatisfyWant: %v", err)
if err := f.store.SatisfyRequest(ctx, id); err != nil {
t.Fatalf("SatisfyRequest: %v", err)
}
w, err = f.store.GetWant(ctx, id)
req, err = f.store.GetRequest(ctx, id)
if err != nil {
t.Fatalf("GetWant after satisfy: %v", err)
t.Fatalf("GetRequest after satisfy: %v", err)
}
if w.State != WantStateSatisfied {
t.Errorf("state = %q, want satisfied", w.State)
if req.State != RequestStateSatisfied {
t.Errorf("state = %q, want satisfied", req.State)
}
}
// Removing an artist subscription takes the albums it derived with it,
// so a user who unsubscribes does not keep downloading that artist.
func TestDeleteArtistWantCascadesToChildren(t *testing.T) {
func TestDeleteArtistRequestCascadesToChildren(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
ctx := context.Background()
artist, err := f.store.AddWant(ctx, Want{
artist, err := f.store.AddRequest(ctx, Request{
MBID: "artist-1",
Entity: EntityArtist,
LibraryID: 1,
Artist: "Radiohead",
})
if err != nil {
t.Fatalf("AddWant artist: %v", err)
t.Fatalf("AddRequest artist: %v", err)
}
if _, err := f.store.AddWant(ctx, Want{
if _, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
ParentID: artist,
}); err != nil {
t.Fatalf("AddWant child: %v", err)
t.Fatalf("AddRequest child: %v", err)
}
children, err := f.store.ListChildWants(ctx, artist)
children, err := f.store.ListChildRequests(ctx, artist)
if err != nil {
t.Fatalf("ListChildWants: %v", err)
t.Fatalf("ListChildRequests: %v", err)
}
if len(children) != 1 {
t.Fatalf("got %d children, want 1", len(children))
}
if err := f.store.DeleteWant(ctx, artist); err != nil {
t.Fatalf("DeleteWant: %v", err)
if err := f.store.DeleteRequest(ctx, artist); err != nil {
t.Fatalf("DeleteRequest: %v", err)
}
all, err := f.store.ListWants(ctx)
all, err := f.store.ListRequests(ctx)
if err != nil {
t.Fatalf("ListWants: %v", err)
t.Fatalf("ListRequests: %v", err)
}
if len(all) != 0 {
t.Errorf("got %d wants after deleting the artist, want 0", len(all))
t.Errorf("got %d requests after deleting the artist, want 0", len(all))
}
}
+309
View File
@@ -0,0 +1,309 @@
package download
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"yellowjacket/backend/database/sql/sqlcgen"
)
// The durable request list's persistence. Kept apart from the
// download/item storage in store.go because the two have opposite
// lifetimes: downloads and items are written constantly and swept,
// requests are written rarely and kept.
// defaultDueBatch bounds how many requests one reconcile pass picks up.
// The list can be thousands of rows after a discography backfill, and a
// pass that tried to search all of them would take a day and annoy
// every provider on the way.
const defaultDueBatch = 25
// AddRequest inserts a request, or returns the existing row's ID if the
// same MBID is already requested in this library. Asking twice is not
// two requests, and re-asking must not reset a backoff that is
// deliberately long.
func (s *Store) AddRequest(ctx context.Context, r Request) (int64, error) {
if !r.Entity.Valid() {
return 0, fmt.Errorf("%w: entity %q", ErrUnsupported, r.Entity)
}
if r.Scope == "" {
r.Scope = ScopeFuture
}
// The MBID is the identity of a request, so it is normalized here
// rather than at each call site: the same identifier arriving from
// an Explore page and from a pasted URL must be one row, or the
// uniqueness constraint that makes artist expansion idempotent
// stops holding.
r.MBID = strings.ToLower(strings.TrimSpace(r.MBID))
if r.MBID == "" {
return 0, fmt.Errorf("%w: a request needs an MBID", ErrUnsupported)
}
parent := sql.NullInt64{}
if r.ParentID != 0 {
parent = sql.NullInt64{Int64: r.ParentID, Valid: true}
}
id, err := s.db.Queries.UpsertDownloadRequest(
ctx,
sqlcgen.UpsertDownloadRequestParams{
Mbid: r.MBID,
Entity: string(r.Entity),
LibraryID: r.LibraryID,
Artist: r.Artist,
Title: r.Title,
Scope: string(r.Scope),
Secondary: boolToInt(r.Secondary),
ParentID: parent,
},
)
if err != nil {
return 0, fmt.Errorf("add download request: %w", err)
}
return id, nil
}
// GetRequest loads one request.
func (s *Store) GetRequest(ctx context.Context, id int64) (Request, error) {
row, err := s.db.ReadQueries.GetDownloadRequest(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Request{}, fmt.Errorf("%w: request %d", ErrNotFound, id)
}
return Request{}, fmt.Errorf("get download request: %w", err)
}
return requestRowToRequest(row), nil
}
// FindRequest looks a request up by what it names rather than by row
// ID, which is how callers holding an MBID (the Explore pages, a
// provider sync) ask "is this already requested?".
func (s *Store) FindRequest(
ctx context.Context,
mbid string,
libraryID int64,
) (Request, bool, error) {
row, err := s.db.ReadQueries.GetDownloadRequestByMBID(
ctx,
sqlcgen.GetDownloadRequestByMBIDParams{Mbid: mbid, LibraryID: libraryID},
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Request{}, false, nil
}
return Request{}, false, fmt.Errorf("find download request: %w", err)
}
return requestRowToRequest(row), true, nil
}
// ListRequests returns the whole durable request list, active first.
func (s *Store) ListRequests(ctx context.Context) ([]Request, error) {
rows, err := s.db.ReadQueries.ListDownloadRequests(ctx)
if err != nil {
return nil, fmt.Errorf("list download requests: %w", err)
}
return requestRowsToRequests(rows), nil
}
// ListActiveRequests returns active artist subscriptions, which are
// what the reconciler expands.
func (s *Store) ListActiveRequests(ctx context.Context) ([]Request, error) {
rows, err := s.db.ReadQueries.ListDownloadRequestsByEntity(
ctx,
sqlcgen.ListDownloadRequestsByEntityParams{
Entity: string(EntityArtist),
State: string(RequestStateWanted),
},
)
if err != nil {
return nil, fmt.Errorf("list artist requests: %w", err)
}
return requestRowsToRequests(rows), nil
}
// ListDueRequests returns downloadable requests whose backoff has
// elapsed, least-attempted first so a new addition is not stuck behind
// a hundred long-shot retries.
func (s *Store) ListDueRequests(ctx context.Context, limit int) ([]Request, error) {
if limit <= 0 {
limit = defaultDueBatch
}
rows, err := s.db.ReadQueries.ListDueDownloadRequests(ctx, int64(limit))
if err != nil {
return nil, fmt.Errorf("list due download requests: %w", err)
}
return requestRowsToRequests(rows), nil
}
// ListChildRequests returns the requests an artist subscription
// produced.
func (s *Store) ListChildRequests(
ctx context.Context,
parentID int64,
) ([]Request, error) {
rows, err := s.db.ReadQueries.ListChildDownloadRequests(
ctx,
sql.NullInt64{Int64: parentID, Valid: true},
)
if err != nil {
return nil, fmt.Errorf("list child download requests: %w", err)
}
return requestRowsToRequests(rows), nil
}
// SetRequestState moves a request between wanted, paused and satisfied.
func (s *Store) SetRequestState(
ctx context.Context,
id int64,
state RequestState,
errText string,
) error {
if err := s.db.Queries.SetDownloadRequestState(
ctx,
sqlcgen.SetDownloadRequestStateParams{
State: string(state),
LastError: errText,
ID: id,
},
); err != nil {
return fmt.Errorf("set download request state: %w", err)
}
return nil
}
// RecordAttempt notes an unsuccessful pass over a request and schedules
// the next one. The request stays wanted: not finding something is a
// fact about today's providers, not a verdict on the request.
func (s *Store) RecordAttempt(
ctx context.Context,
id int64,
attempts int,
reason string,
) error {
next := nextRetry(time.Now(), attempts+1)
if err := s.db.Queries.RecordDownloadRequestAttempt(
ctx,
sqlcgen.RecordDownloadRequestAttemptParams{
LastError: reason,
NextTryAt: sql.NullTime{Time: next, Valid: true},
ID: id,
},
); err != nil {
return fmt.Errorf("record download request attempt: %w", err)
}
return nil
}
// SatisfyRequest marks a request as owned.
func (s *Store) SatisfyRequest(ctx context.Context, id int64) error {
if err := s.db.Queries.SatisfyDownloadRequest(ctx, id); err != nil {
return fmt.Errorf("satisfy download request: %w", err)
}
return nil
}
// SetRequestExternalIDs records the identifiers external managers gave
// this request in their own persistent lists.
func (s *Store) SetRequestExternalIDs(
ctx context.Context,
id int64,
ids map[string]string,
) error {
encoded, err := json.Marshal(ids)
if err != nil {
return fmt.Errorf("encode request external ids: %w", err)
}
if err := s.db.Queries.SetDownloadRequestExternalIDs(
ctx,
sqlcgen.SetDownloadRequestExternalIDsParams{
ExternalIds: string(encoded),
ID: id,
},
); err != nil {
return fmt.Errorf("set request external ids: %w", err)
}
return nil
}
// DeleteRequest removes a request and, by cascade, anything an artist
// request derived.
func (s *Store) DeleteRequest(ctx context.Context, id int64) error {
if err := s.db.Queries.DeleteDownloadRequest(ctx, id); err != nil {
return fmt.Errorf("delete download request: %w", err)
}
return nil
}
// ClearSatisfiedRequests drops everything already owned.
func (s *Store) ClearSatisfiedRequests(ctx context.Context) error {
if err := s.db.Queries.DeleteSatisfiedDownloadRequests(ctx); err != nil {
return fmt.Errorf("clear satisfied download requests: %w", err)
}
return nil
}
// requestRowsToRequests decodes a slice of stored rows.
func requestRowsToRequests(rows []sqlcgen.DownloadRequest) []Request {
out := make([]Request, 0, len(rows))
for _, r := range rows {
out = append(out, requestRowToRequest(r))
}
return out
}
// requestRowToRequest decodes a stored request row. A malformed
// external-ID blob yields an empty map rather than an error: losing the
// link to a Lidarr row is recoverable on the next sync, making the
// request list unreadable is not.
func requestRowToRequest(r sqlcgen.DownloadRequest) Request {
external := map[string]string{}
_ = json.Unmarshal([]byte(r.ExternalIds), &external)
return Request{
ID: r.ID,
MBID: r.Mbid,
Entity: Entity(r.Entity),
LibraryID: r.LibraryID,
Artist: r.Artist,
Title: r.Title,
Scope: RequestScope(r.Scope),
Secondary: r.Secondary != 0,
State: RequestState(r.State),
ParentID: r.ParentID.Int64,
Attempts: int(r.Attempts),
LastError: r.LastError,
LastTriedAt: r.LastTriedAt.Time,
NextTryAt: r.NextTryAt.Time,
ExternalIDs: external,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
+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,
)
+188
View File
@@ -0,0 +1,188 @@
package download
import (
"context"
"testing"
)
// newServiceFixture wires a Service over the same manager/store a
// managerFixture uses, so a manual download can be started and watched
// through to completion with no network anywhere.
type serviceFixture struct {
managerFixture
svc *Service
}
func newServiceFixture(t *testing.T) serviceFixture {
t.Helper()
mf := newManagerFixture(t)
svc := NewService(slogDiscard(), mf.manager, mf.store, NewMemSecretStore())
return serviceFixture{managerFixture: mf, svc: svc}
}
// A manual download for something anchored by MBID must leave a
// durable Request behind, whether or not the download itself succeeds
// — that is the whole point of ensureRequest: a manual attempt that
// finds nothing right now is not just lost, the reconciler picks it up
// later on its normal schedule.
func TestStartDownloadCreatesRequestForAnchoredDownload(t *testing.T) {
t.Parallel()
f := newServiceFixture(t)
ctx := context.Background()
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
dl := fourTrackDownload()
if _, err := f.svc.StartDownload(SearchRequest{
LibraryID: 1,
ReleaseGroupMBID: "rg-1",
Artist: dl.Artist,
Album: dl.Album,
Expected: dl.Expected,
}); err != nil {
t.Fatalf("StartDownload: %v", err)
}
req, found, err := f.store.FindRequest(ctx, "rg-1", 1)
if err != nil {
t.Fatalf("FindRequest: %v", err)
}
if !found {
t.Fatal("manual anchored download did not create a durable request")
}
if req.Entity != EntityReleaseGroup {
t.Errorf("entity = %q, want release-group", req.Entity)
}
if req.State != RequestStateWanted {
t.Errorf("state = %q, want wanted", req.State)
}
}
// A free-text download (no MBID) has nothing stable to attach a
// request to, and must not create one.
func TestStartDownloadFreeTextCreatesNoRequest(t *testing.T) {
t.Parallel()
f := newServiceFixture(t)
ctx := context.Background()
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
if _, err := f.svc.StartDownload(SearchRequest{
LibraryID: 1,
Query: "some free text search",
}); err != nil {
t.Fatalf("StartDownload: %v", err)
}
all, err := f.store.ListRequests(ctx)
if err != nil {
t.Fatalf("ListRequests: %v", err)
}
if len(all) != 0 {
t.Errorf("free-text download created %d requests, want 0", len(all))
}
}
// A manual download must not un-pause a request the user deliberately
// paused: it runs its one interactive attempt regardless, but the
// request's own state is left alone.
func TestStartDownloadDoesNotUnpauseExistingRequest(t *testing.T) {
t.Parallel()
f := newServiceFixture(t)
ctx := context.Background()
id, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
Artist: "Radiohead",
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddRequest: %v", err)
}
if err := f.store.SetRequestState(
ctx, id, RequestStatePaused, "",
); err != nil {
t.Fatalf("SetRequestState: %v", err)
}
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
dl := fourTrackDownload()
if _, err := f.svc.StartDownload(SearchRequest{
LibraryID: 1,
ReleaseGroupMBID: "rg-1",
Artist: dl.Artist,
Album: dl.Album,
Expected: dl.Expected,
}); err != nil {
t.Fatalf("StartDownload: %v", err)
}
req, err := f.store.GetRequest(ctx, id)
if err != nil {
t.Fatalf("GetRequest: %v", err)
}
if req.State != RequestStatePaused {
t.Errorf(
"a manual download un-paused the request: state = %q, want paused",
req.State,
)
}
}
// A manual download that clearly wins auto-pick still satisfies the
// durable request it was attached to when it completes — the same
// SatisfyRequest call the reconciler relies on.
func TestManualDownloadSatisfiesRequestOnSuccess(t *testing.T) {
t.Parallel()
f := newServiceFixture(t)
ctx := context.Background()
provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
dl := fourTrackDownload()
result, err := f.svc.StartDownload(SearchRequest{
LibraryID: 1,
ReleaseGroupMBID: "rg-1",
Artist: dl.Artist,
Album: dl.Album,
Expected: dl.Expected,
})
if err != nil {
t.Fatalf("StartDownload: %v", err)
}
if !result.AutoPicked {
t.Fatal("expected a clear single-provider winner to auto-pick")
}
waitForDownloadState(t, f.store, result.DownloadID, StateComplete)
waitFor(t, func() bool {
req, found, err := f.store.FindRequest(ctx, "rg-1", 1)
return err == nil && found && req.State == RequestStateSatisfied
}, "request was never satisfied after its manual download completed")
}
+80 -73
View File
@@ -137,148 +137,148 @@ func providerRowToConfig(r sqlcgen.DownloadProvider) Config {
}
// ---------------------------------------------------------------------------
// Requests
// Downloads
// ---------------------------------------------------------------------------
// CreateRequest persists a new request.
func (s *Store) CreateRequest(ctx context.Context, req Request) error {
expected, err := json.Marshal(req.Expected)
// CreateDownload persists a new download.
func (s *Store) CreateDownload(ctx context.Context, dl Download) error {
expected, err := json.Marshal(dl.Expected)
if err != nil {
return fmt.Errorf("encode expected tracks: %w", err)
}
source := req.Source
source := dl.Source
if source == "" {
source = "manual"
}
wantID := sql.NullInt64{}
if req.WantID != 0 {
wantID = sql.NullInt64{Int64: req.WantID, Valid: true}
requestID := sql.NullInt64{}
if dl.RequestID != 0 {
requestID = sql.NullInt64{Int64: dl.RequestID, Valid: true}
}
if err := s.db.Queries.CreateDownloadRequest(
if err := s.db.Queries.CreateDownload(
ctx,
sqlcgen.CreateDownloadRequestParams{
ID: req.ID,
LibraryID: req.LibraryID,
sqlcgen.CreateDownloadParams{
ID: dl.ID,
LibraryID: dl.LibraryID,
Source: source,
WantID: wantID,
ReleaseMbid: toNullString(req.ReleaseMBID),
ReleaseGroupMbid: toNullString(req.ReleaseGroupMBID),
RecordingMbid: toNullString(req.RecordingMBID),
Artist: req.Artist,
Album: req.Album,
Query: req.Query,
RequestID: requestID,
ReleaseMbid: toNullString(dl.ReleaseMBID),
ReleaseGroupMbid: toNullString(dl.ReleaseGroupMBID),
RecordingMbid: toNullString(dl.RecordingMBID),
Artist: dl.Artist,
Album: dl.Album,
Query: dl.Query,
Expected: string(expected),
State: string(StateSearching),
},
); err != nil {
return fmt.Errorf("create download request: %w", err)
return fmt.Errorf("create download: %w", err)
}
return nil
}
// GetRequest loads a request by ID.
func (s *Store) GetRequest(ctx context.Context, id string) (Request, error) {
row, err := s.db.ReadQueries.GetDownloadRequest(ctx, id)
// GetDownload loads a download by ID.
func (s *Store) GetDownload(ctx context.Context, id string) (Download, error) {
row, err := s.db.ReadQueries.GetDownload(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Request{}, fmt.Errorf("%w: request %s", ErrNotFound, id)
return Download{}, fmt.Errorf("%w: download %s", ErrNotFound, id)
}
return Request{}, fmt.Errorf("get download request: %w", err)
return Download{}, fmt.Errorf("get download: %w", err)
}
return requestRowToRequest(row), nil
return downloadRowToDownload(row), nil
}
// GetRequestState returns a request's current state and error text.
// Kept separate from GetRequest because state is the one field that
// GetDownloadState returns a download's current state and error text.
// Kept separate from GetDownload because state is the one field that
// changes constantly while the rest of the row is immutable.
func (s *Store) GetRequestState(
func (s *Store) GetDownloadState(
ctx context.Context,
id string,
) (State, string, error) {
row, err := s.db.ReadQueries.GetDownloadRequest(ctx, id)
row, err := s.db.ReadQueries.GetDownload(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", "", fmt.Errorf("%w: request %s", ErrNotFound, id)
return "", "", fmt.Errorf("%w: download %s", ErrNotFound, id)
}
return "", "", fmt.Errorf("get download request state: %w", err)
return "", "", fmt.Errorf("get download state: %w", err)
}
return State(row.State), row.Error, nil
}
// ListRequests returns the most recent requests, newest first.
func (s *Store) ListRequests(ctx context.Context, limit int) ([]Request, error) {
rows, err := s.db.ReadQueries.ListDownloadRequests(ctx, int64(limit))
// ListDownloads returns the most recent downloads, newest first.
func (s *Store) ListDownloads(ctx context.Context, limit int) ([]Download, error) {
rows, err := s.db.ReadQueries.ListDownloads(ctx, int64(limit))
if err != nil {
return nil, fmt.Errorf("list download requests: %w", err)
return nil, fmt.Errorf("list downloads: %w", err)
}
out := make([]Request, 0, len(rows))
out := make([]Download, 0, len(rows))
for _, r := range rows {
out = append(out, requestRowToRequest(r))
out = append(out, downloadRowToDownload(r))
}
return out, nil
}
// SetRequestState updates a request's state and error text.
func (s *Store) SetRequestState(
// SetDownloadState updates a download's state and error text.
func (s *Store) SetDownloadState(
ctx context.Context,
id string,
state State,
errText string,
) error {
if err := s.db.Queries.SetDownloadRequestState(
if err := s.db.Queries.SetDownloadState(
ctx,
sqlcgen.SetDownloadRequestStateParams{
sqlcgen.SetDownloadStateParams{
State: string(state),
Error: errText,
ID: id,
},
); err != nil {
return fmt.Errorf("set download request state: %w", err)
return fmt.Errorf("set download state: %w", err)
}
return nil
}
// DeleteRequest removes a request and, by cascade, its items.
func (s *Store) DeleteRequest(ctx context.Context, id string) error {
if err := s.db.Queries.DeleteDownloadRequest(ctx, id); err != nil {
return fmt.Errorf("delete download request: %w", err)
// DeleteDownload removes a download and, by cascade, its items.
func (s *Store) DeleteDownload(ctx context.Context, id string) error {
if err := s.db.Queries.DeleteDownload(ctx, id); err != nil {
return fmt.Errorf("delete download: %w", err)
}
return nil
}
// ClearFinished removes every terminal request.
// ClearFinished removes every terminal download.
func (s *Store) ClearFinished(ctx context.Context) error {
if err := s.db.Queries.DeleteFinishedDownloadRequests(ctx); err != nil {
return fmt.Errorf("clear finished download requests: %w", err)
if err := s.db.Queries.DeleteFinishedDownloads(ctx); err != nil {
return fmt.Errorf("clear finished downloads: %w", err)
}
return nil
}
// requestRowToRequest decodes a stored request row.
func requestRowToRequest(r sqlcgen.DownloadRequest) Request {
// downloadRowToDownload decodes a stored download row.
func downloadRowToDownload(r sqlcgen.DownloadDownload) Download {
var expected []ExpectedTrack
_ = json.Unmarshal([]byte(r.Expected), &expected)
return Request{
return Download{
ID: r.ID,
LibraryID: r.LibraryID,
Source: r.Source,
WantID: r.WantID.Int64,
RequestID: r.RequestID.Int64,
ReleaseMBID: r.ReleaseMbid.String,
ReleaseGroupMBID: r.ReleaseGroupMbid.String,
RecordingMBID: r.RecordingMbid.String,
@@ -294,10 +294,16 @@ func requestRowToRequest(r sqlcgen.DownloadRequest) Request {
// Items
// ---------------------------------------------------------------------------
// Item is one grab attempt, as stored.
type Item struct {
// DownloadItem is one grab attempt, as stored.
//
// deliberate: distinguishes it from download.Download (the attempt) and
// download.Request (the durable record) at every call site, which a bare
// "Item" would not.
//
//nolint:revive // stutters as download.DownloadItem, but the name is
type DownloadItem struct {
ID string `json:"id"`
RequestID string `json:"requestId"`
DownloadID string `json:"downloadId"`
ProviderID int64 `json:"providerId"`
Transport int64 `json:"transportId,omitempty"`
ExternalID string `json:"externalId,omitempty"`
@@ -313,7 +319,7 @@ type Item struct {
}
// CreateItem persists a grab attempt.
func (s *Store) CreateItem(ctx context.Context, item Item) error {
func (s *Store) CreateItem(ctx context.Context, item DownloadItem) error {
candidate, err := json.Marshal(item.Candidate)
if err != nil {
return fmt.Errorf("encode candidate: %w", err)
@@ -328,7 +334,7 @@ func (s *Store) CreateItem(ctx context.Context, item Item) error {
ctx,
sqlcgen.CreateDownloadItemParams{
ID: item.ID,
RequestID: item.RequestID,
DownloadID: item.DownloadID,
ProviderID: item.ProviderID,
TransportID: transport,
ExternalID: item.ExternalID,
@@ -345,30 +351,31 @@ func (s *Store) CreateItem(ctx context.Context, item Item) error {
}
// GetItem loads one item.
func (s *Store) GetItem(ctx context.Context, id string) (Item, error) {
func (s *Store) GetItem(ctx context.Context, id string) (DownloadItem, error) {
row, err := s.db.ReadQueries.GetDownloadItem(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Item{}, fmt.Errorf("%w: item %s", ErrNotFound, id)
return DownloadItem{}, fmt.Errorf("%w: item %s", ErrNotFound, id)
}
return Item{}, fmt.Errorf("get download item: %w", err)
return DownloadItem{}, fmt.Errorf("get download item: %w", err)
}
return itemRowToItem(row), nil
}
// ListItemsForRequest returns a request's grab attempts, oldest first.
func (s *Store) ListItemsForRequest(
// ListItemsForDownload returns a download's grab attempts, oldest
// first.
func (s *Store) ListItemsForDownload(
ctx context.Context,
requestID string,
) ([]Item, error) {
rows, err := s.db.ReadQueries.ListDownloadItemsForRequest(ctx, requestID)
downloadID string,
) ([]DownloadItem, error) {
rows, err := s.db.ReadQueries.ListDownloadItemsForDownload(ctx, downloadID)
if err != nil {
return nil, fmt.Errorf("list download items: %w", err)
}
out := make([]Item, 0, len(rows))
out := make([]DownloadItem, 0, len(rows))
for _, r := range rows {
out = append(out, itemRowToItem(r))
@@ -379,13 +386,13 @@ func (s *Store) ListItemsForRequest(
// ListLiveItems returns every non-terminal item. Called at startup to
// decide what to resume, reconcile or abandon.
func (s *Store) ListLiveItems(ctx context.Context) ([]Item, error) {
func (s *Store) ListLiveItems(ctx context.Context) ([]DownloadItem, error) {
rows, err := s.db.ReadQueries.ListLiveDownloadItems(ctx)
if err != nil {
return nil, fmt.Errorf("list live download items: %w", err)
}
out := make([]Item, 0, len(rows))
out := make([]DownloadItem, 0, len(rows))
for _, r := range rows {
out = append(out, itemRowToItem(r))
@@ -479,7 +486,7 @@ func (s *Store) SetItemImported(
}
// itemRowToItem decodes a stored item row.
func itemRowToItem(r sqlcgen.DownloadItem) Item {
func itemRowToItem(r sqlcgen.DownloadItem) DownloadItem {
var (
candidate Candidate
imported []string
@@ -488,9 +495,9 @@ func itemRowToItem(r sqlcgen.DownloadItem) Item {
_ = json.Unmarshal([]byte(r.Candidate), &candidate)
_ = json.Unmarshal([]byte(r.ImportedPaths), &imported)
return Item{
return DownloadItem{
ID: r.ID,
RequestID: r.RequestID,
DownloadID: r.DownloadID,
ProviderID: r.ProviderID,
Transport: r.TransportID.Int64,
ExternalID: r.ExternalID,
+33 -25
View File
@@ -82,29 +82,36 @@ func (c Caps) Handles(p Protocol) bool {
return slices.Contains(c.Transports, p)
}
// Request is what the user asked for. Requests that carry a MusicBrainz
// anchor are far more reliable than free-text ones, because the anchor
// gives the import step an expected tracklist to match against — so the
// pipeline records which it got and refuses to auto-pick without one.
type Request struct {
// Download is one search-and-grab attempt: it searches, it grabs, it
// succeeds or fails, and then it is history. A Download that carries a
// MusicBrainz anchor is far more reliable than a free-text one, because
// the anchor gives the import step an expected tracklist to match
// against — so the pipeline records which it got and refuses to
// auto-pick without one.
//
// A Download is not the same thing as a Request (request.go): a
// Request is durable and outlives every attempt made on its behalf,
// while a Download is one such attempt and is disposable.
type Download struct {
ID string `json:"id"`
// Anchors. Any may be empty; all empty means free-text.
ReleaseMBID string `json:"releaseMbid,omitempty"`
ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"`
// RecordingMBID anchors a single-track request. Its Expected holds
// exactly that one track, which is what lets a track request be
// RecordingMBID anchors a single-track download. Its Expected holds
// exactly that one track, which is what lets a track download be
// scored — and therefore auto-picked — on the same footing as an
// album.
RecordingMBID string `json:"recordingMbid,omitempty"`
// WantID links back to the wanted-list row this request was raised
// for, or 0 for a request the user started by hand. The reconciler
// writes the outcome back through it.
WantID int64 `json:"wantId,omitempty"`
// RequestID links back to the durable Request row this download was
// raised for or attached to, or 0 for a free-text download with
// nothing stable to attach to. The reconciler and manual anchored
// downloads both write the outcome back through it.
RequestID int64 `json:"requestId,omitempty"`
// Source records where the request came from, for the downloads
// Source records where the download came from, for the downloads
// list. Empty means "manual".
Source string `json:"source,omitempty"`
@@ -116,7 +123,7 @@ type Request struct {
// Expected is the tracklist the anchor resolves to, used for
// completeness scoring and for the autotag match at import. Empty
// for free-text requests.
// for free-text downloads.
Expected []ExpectedTrack `json:"expected,omitempty"`
// LibraryID is the library imported files belong to.
@@ -125,12 +132,12 @@ type Request struct {
CreatedAt time.Time `json:"createdAt"`
}
// Anchored reports whether the request carries a MusicBrainz ID. Only
// anchored requests are eligible for auto-pick.
func (r Request) Anchored() bool {
return r.ReleaseMBID != "" ||
r.ReleaseGroupMBID != "" ||
r.RecordingMBID != ""
// Anchored reports whether the download carries a MusicBrainz ID. Only
// anchored downloads are eligible for auto-pick.
func (d Download) Anchored() bool {
return d.ReleaseMBID != "" ||
d.ReleaseGroupMBID != "" ||
d.RecordingMBID != ""
}
// SearchText returns the string to hand a provider's search endpoint.
@@ -142,16 +149,16 @@ func (r Request) Anchored() bool {
// return zero results on providers that expect every term to appear
// in a match (Soulseek in particular), so the artist is dropped when
// the album title already leads with it.
func (r Request) SearchText() string {
if r.Query != "" {
return r.Query
func (d Download) SearchText() string {
if d.Query != "" {
return d.Query
}
if r.Artist != "" && albumLeadsWithArtist(r.Artist, r.Album) {
return strings.TrimSpace(r.Album)
if d.Artist != "" && albumLeadsWithArtist(d.Artist, d.Album) {
return strings.TrimSpace(d.Album)
}
return strings.TrimSpace(r.Artist + " " + r.Album)
return strings.TrimSpace(d.Artist + " " + d.Album)
}
// albumLeadsWithArtist reports whether album starts with artist as a
@@ -295,6 +302,7 @@ type QualityScore struct {
Bitrate float64 `json:"bitrate"`
Health float64 `json:"health"` // seeders, free slots
Priority float64 `json:"priority"` // user's per-provider preference
SizeFit float64 `json:"sizeFit"` // closeness to the preferred download size
// Mixed marks a candidate whose files are not all the same format,
// which usually means a hand-assembled folder rather than a rip.
+10 -10
View File
@@ -2,52 +2,52 @@ package download
import "testing"
func TestRequest_SearchText(t *testing.T) {
func TestDownload_SearchText(t *testing.T) {
tests := []struct {
name string
req Request
dl Download
want string
}{
{
name: "query overrides everything",
req: Request{Artist: "Blank Banshee", Album: "0", Query: "raw text"},
dl: Download{Artist: "Blank Banshee", Album: "0", Query: "raw text"},
want: "raw text",
},
{
name: "ordinary album keeps artist and album",
req: Request{Artist: "Pink Floyd", Album: "The Wall"},
dl: Download{Artist: "Pink Floyd", Album: "The Wall"},
want: "Pink Floyd The Wall",
},
{
name: "album title leads with artist name",
req: Request{Artist: "Blank Banshee", Album: "Blank Banshee 0"},
dl: Download{Artist: "Blank Banshee", Album: "Blank Banshee 0"},
want: "Blank Banshee 0",
},
{
name: "self-titled album",
req: Request{Artist: "Boston", Album: "Boston"},
dl: Download{Artist: "Boston", Album: "Boston"},
want: "Boston",
},
{
name: "artist name as a substring, not a word prefix",
req: Request{Artist: "Air", Album: "Repair"},
dl: Download{Artist: "Air", Album: "Repair"},
want: "Air Repair",
},
{
name: "case-insensitive match",
req: Request{Artist: "blank banshee", Album: "BLANK BANSHEE 0"},
dl: Download{Artist: "blank banshee", Album: "BLANK BANSHEE 0"},
want: "BLANK BANSHEE 0",
},
{
name: "no artist",
req: Request{Album: "Compilation"},
dl: Download{Album: "Compilation"},
want: "Compilation",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.req.SearchText(); got != tt.want {
if got := tt.dl.SearchText(); got != tt.want {
t.Errorf("SearchText() = %q, want %q", got, tt.want)
}
})
-236
View File
@@ -1,236 +0,0 @@
package download
import (
"math"
"math/rand/v2"
"time"
)
// A Want is a persistent "I want this", stored as a MusicBrainz ID and
// almost nothing else.
//
// The distinction from Request is the whole point of this file. A
// Request is one attempt: it searches, it grabs, it succeeds or fails,
// and then it is history. A Want outlives every attempt made on its
// behalf. Nothing being findable today is the normal case for obscure
// music, and the correct response is to try again next week, not to
// show the user a failed row they have to remember to retry.
//
// Because a Want is only an MBID, it stays true when everything around
// it changes: the explore index is rebuilt, a provider is swapped out,
// the release the user originally saw is superseded by a remaster. The
// display fields are a cache for the list view and are never consulted
// for matching.
// Entity says what a want's MBID names, and is the only type
// distinction the wanted list makes.
type Entity string
// Want entity types.
const (
// EntityArtist is a subscription rather than a thing to fetch: it
// is never satisfied, and each reconcile expands the artist's
// discography into child wants.
EntityArtist Entity = "artist"
// EntityReleaseGroup is an album in the abstract — any release of
// it satisfies the want, which is what a user means by "I want this
// album".
EntityReleaseGroup Entity = "release-group"
// EntityRelease is one specific edition, used when the user picked
// a particular pressing.
EntityRelease Entity = "release"
// EntityRecording is a single track.
EntityRecording Entity = "recording"
)
// Valid reports whether e is a known entity type.
func (e Entity) Valid() bool {
switch e {
case EntityArtist, EntityReleaseGroup, EntityRelease, EntityRecording:
return true
default:
return false
}
}
// Expands reports whether this entity produces child wants rather than
// being downloaded directly.
func (e Entity) Expands() bool {
return e == EntityArtist
}
// WantState is where a want sits. There is deliberately no "failed":
// an attempt can fail, a want cannot. A want that has tried and not
// found anything is still wanted, with attempts and last_error
// recording why it is taking a while.
type WantState string
// Want states.
const (
// WantStateWanted is the active state: due for another attempt when
// its backoff elapses.
WantStateWanted WantState = "wanted"
// WantStateSatisfied means the library owns it. How it got there —
// downloaded here, ripped, bought elsewhere — does not matter.
WantStateSatisfied WantState = "satisfied"
// WantStatePaused is the user saying "keep this on the list but
// stop trying".
WantStatePaused WantState = "paused"
)
// WantScope applies to artist wants only.
type WantScope string
// Artist want scopes.
const (
// ScopeFuture takes only releases first published after the artist
// was added. Default, because subscribing to an artist should not
// silently queue their entire back catalogue.
ScopeFuture WantScope = "future"
// ScopeAll backfills the whole discography as well.
ScopeAll WantScope = "all"
)
// Want is one row of the wanted list.
type Want struct {
ID int64 `json:"id"`
MBID string `json:"mbid"`
Entity Entity `json:"entity"`
LibraryID int64 `json:"libraryId"`
// Artist and Title are display cache only. Matching always uses
// the MBID.
Artist string `json:"artist"`
Title string `json:"title"`
Scope WantScope `json:"scope"`
// Secondary includes compilations, live albums and remixes in an
// artist want's expansion.
Secondary bool `json:"secondary"`
State WantState `json:"state"`
// ParentID is set on wants the reconciler derived from an artist
// subscription. A want the user pinned directly has none, so
// removing the artist leaves it alone.
ParentID int64 `json:"parentId,omitempty"`
Attempts int `json:"attempts"`
LastError string `json:"lastError,omitempty"`
LastTriedAt time.Time `json:"lastTriedAt,omitempty"`
NextTryAt time.Time `json:"nextTryAt,omitempty"`
// ExternalIDs maps provider row ID (as a string, because JSON
// object keys are strings) to that provider's own identifier for
// this want. Only set for providers that keep a persistent list of
// their own.
ExternalIDs map[string]string `json:"externalIds,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Anchored is always true for a want: it is an MBID by construction.
// The method exists so wants and requests read the same at call sites.
func (w Want) Anchored() bool { return w.MBID != "" }
// Label is the wanted list's one-line description of a want.
func (w Want) Label() string {
switch {
case w.Artist != "" && w.Title != "":
return w.Artist + " — " + w.Title
case w.Title != "":
return w.Title
case w.Artist != "":
return w.Artist
default:
return string(w.Entity) + " " + w.MBID
}
}
// Retry backoff. A want that cannot be found is usually one that will
// not be findable for a while — a pre-release, something only ever on
// physical media, an artist no source indexes — so the schedule climbs
// fast and then sits at a weekly poll rather than hammering providers
// with the same fruitless search.
const (
// wantRetryBase is the delay after the first unsuccessful attempt.
wantRetryBase = 6 * time.Hour
// wantRetryMax caps the backoff. A weekly retry on a list of a few
// hundred wants is a handful of searches a day, which every
// provider tolerates.
wantRetryMax = 7 * 24 * time.Hour
// wantRetryJitter spreads retries so a list added in one sitting
// does not come due in one burst.
wantRetryJitter = 0.2
)
// nextRetry returns when a want with the given attempt count should be
// tried again: exponential from wantRetryBase, capped at wantRetryMax,
// jittered so a batch added together does not stay in lockstep forever.
func nextRetry(now time.Time, attempts int) time.Time {
if attempts < 1 {
attempts = 1
}
// Cap the exponent before shifting so a long-lived want cannot
// overflow the duration into something negative.
const maxExp = 16
exp := min(attempts-1, maxExp)
delay := float64(wantRetryBase) * math.Pow(2, float64(exp))
if delay > float64(wantRetryMax) {
delay = float64(wantRetryMax)
}
jitter := delay * wantRetryJitter * (rand.Float64()*2 - 1) //nolint:gosec // spreading retries, not a secret
return now.Add(time.Duration(delay + jitter))
}
// wantSource is the request source recorded for reconciler-raised
// requests, so the downloads list can tell them apart from the ones a
// user started by hand.
const wantSource = "wanted"
// ToRequest builds the download request that would satisfy this want.
// Expected is filled by the caller from the catalog, since resolving a
// tracklist is I/O and this is not.
func (w Want) ToRequest(id string) Request {
req := Request{
ID: id,
LibraryID: w.LibraryID,
Artist: w.Artist,
Album: w.Title,
WantID: w.ID,
Source: wantSource,
}
switch w.Entity {
case EntityRelease:
req.ReleaseMBID = w.MBID
case EntityReleaseGroup:
req.ReleaseGroupMBID = w.MBID
case EntityRecording:
// A recording has no release anchor, so ranking has only the
// title to go on and auto-pick stays off. The MBID is still
// carried in RecordingMBID so a provider that can use it does.
req.RecordingMBID = w.MBID
case EntityArtist:
// Artist wants expand into children and are never turned into
// a request directly; this case exists so the switch is
// exhaustive rather than because it can happen.
}
return req
}
-307
View File
@@ -1,307 +0,0 @@
package download
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"yellowjacket/backend/database/sql/sqlcgen"
)
// The wanted list's persistence. Kept apart from the request/item
// storage in store.go because the two have opposite lifetimes: items
// are written constantly and swept, wants are written rarely and kept.
// defaultDueBatch bounds how many wants one reconcile pass picks up.
// The list can be thousands of rows after a discography backfill, and a
// pass that tried to search all of them would take a day and annoy
// every provider on the way.
const defaultDueBatch = 25
// AddWant inserts a want, or returns the existing row's ID if the same
// MBID is already wanted in this library. Asking twice is not two
// wants, and re-asking must not reset a backoff that is deliberately
// long.
func (s *Store) AddWant(ctx context.Context, w Want) (int64, error) {
if !w.Entity.Valid() {
return 0, fmt.Errorf("%w: entity %q", ErrUnsupported, w.Entity)
}
if w.Scope == "" {
w.Scope = ScopeFuture
}
// The MBID is the identity of a want, so it is normalized here
// rather than at each call site: the same identifier arriving from
// an Explore page and from a pasted URL must be one row, or the
// uniqueness constraint that makes artist expansion idempotent
// stops holding.
w.MBID = strings.ToLower(strings.TrimSpace(w.MBID))
if w.MBID == "" {
return 0, fmt.Errorf("%w: a want needs an MBID", ErrUnsupported)
}
parent := sql.NullInt64{}
if w.ParentID != 0 {
parent = sql.NullInt64{Int64: w.ParentID, Valid: true}
}
id, err := s.db.Queries.UpsertDownloadWant(
ctx,
sqlcgen.UpsertDownloadWantParams{
Mbid: w.MBID,
Entity: string(w.Entity),
LibraryID: w.LibraryID,
Artist: w.Artist,
Title: w.Title,
Scope: string(w.Scope),
Secondary: boolToInt(w.Secondary),
ParentID: parent,
},
)
if err != nil {
return 0, fmt.Errorf("add download want: %w", err)
}
return id, nil
}
// GetWant loads one want.
func (s *Store) GetWant(ctx context.Context, id int64) (Want, error) {
row, err := s.db.ReadQueries.GetDownloadWant(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Want{}, fmt.Errorf("%w: want %d", ErrNotFound, id)
}
return Want{}, fmt.Errorf("get download want: %w", err)
}
return wantRowToWant(row), nil
}
// FindWant looks a want up by what it names rather than by row ID,
// which is how callers holding an MBID (the Explore pages, a provider
// sync) ask "is this already wanted?".
func (s *Store) FindWant(
ctx context.Context,
mbid string,
libraryID int64,
) (Want, bool, error) {
row, err := s.db.ReadQueries.GetDownloadWantByMBID(
ctx,
sqlcgen.GetDownloadWantByMBIDParams{Mbid: mbid, LibraryID: libraryID},
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Want{}, false, nil
}
return Want{}, false, fmt.Errorf("find download want: %w", err)
}
return wantRowToWant(row), true, nil
}
// ListWants returns the whole wanted list, active first.
func (s *Store) ListWants(ctx context.Context) ([]Want, error) {
rows, err := s.db.ReadQueries.ListDownloadWants(ctx)
if err != nil {
return nil, fmt.Errorf("list download wants: %w", err)
}
return wantRowsToWants(rows), nil
}
// ListArtistWants returns active artist subscriptions, which are what
// the reconciler expands.
func (s *Store) ListArtistWants(ctx context.Context) ([]Want, error) {
rows, err := s.db.ReadQueries.ListDownloadWantsByEntity(
ctx,
sqlcgen.ListDownloadWantsByEntityParams{
Entity: string(EntityArtist),
State: string(WantStateWanted),
},
)
if err != nil {
return nil, fmt.Errorf("list artist wants: %w", err)
}
return wantRowsToWants(rows), nil
}
// ListDueWants returns downloadable wants whose backoff has elapsed,
// least-attempted first so a new addition is not stuck behind a
// hundred long-shot retries.
func (s *Store) ListDueWants(ctx context.Context, limit int) ([]Want, error) {
if limit <= 0 {
limit = defaultDueBatch
}
rows, err := s.db.ReadQueries.ListDueDownloadWants(ctx, int64(limit))
if err != nil {
return nil, fmt.Errorf("list due download wants: %w", err)
}
return wantRowsToWants(rows), nil
}
// ListChildWants returns the wants an artist subscription produced.
func (s *Store) ListChildWants(
ctx context.Context,
parentID int64,
) ([]Want, error) {
rows, err := s.db.ReadQueries.ListChildDownloadWants(
ctx,
sql.NullInt64{Int64: parentID, Valid: true},
)
if err != nil {
return nil, fmt.Errorf("list child download wants: %w", err)
}
return wantRowsToWants(rows), nil
}
// SetWantState moves a want between wanted, paused and satisfied.
func (s *Store) SetWantState(
ctx context.Context,
id int64,
state WantState,
errText string,
) error {
if err := s.db.Queries.SetDownloadWantState(
ctx,
sqlcgen.SetDownloadWantStateParams{
State: string(state),
LastError: errText,
ID: id,
},
); err != nil {
return fmt.Errorf("set download want state: %w", err)
}
return nil
}
// RecordAttempt notes an unsuccessful pass over a want and schedules
// the next one. The want stays wanted: not finding something is a fact
// about today's providers, not a verdict on the request.
func (s *Store) RecordAttempt(
ctx context.Context,
id int64,
attempts int,
reason string,
) error {
next := nextRetry(time.Now(), attempts+1)
if err := s.db.Queries.RecordDownloadWantAttempt(
ctx,
sqlcgen.RecordDownloadWantAttemptParams{
LastError: reason,
NextTryAt: sql.NullTime{Time: next, Valid: true},
ID: id,
},
); err != nil {
return fmt.Errorf("record download want attempt: %w", err)
}
return nil
}
// SatisfyWant marks a want as owned.
func (s *Store) SatisfyWant(ctx context.Context, id int64) error {
if err := s.db.Queries.SatisfyDownloadWant(ctx, id); err != nil {
return fmt.Errorf("satisfy download want: %w", err)
}
return nil
}
// SetWantExternalIDs records the identifiers external managers gave
// this want in their own persistent lists.
func (s *Store) SetWantExternalIDs(
ctx context.Context,
id int64,
ids map[string]string,
) error {
encoded, err := json.Marshal(ids)
if err != nil {
return fmt.Errorf("encode want external ids: %w", err)
}
if err := s.db.Queries.SetDownloadWantExternalIDs(
ctx,
sqlcgen.SetDownloadWantExternalIDsParams{
ExternalIds: string(encoded),
ID: id,
},
); err != nil {
return fmt.Errorf("set want external ids: %w", err)
}
return nil
}
// DeleteWant removes a want and, by cascade, anything an artist want
// derived.
func (s *Store) DeleteWant(ctx context.Context, id int64) error {
if err := s.db.Queries.DeleteDownloadWant(ctx, id); err != nil {
return fmt.Errorf("delete download want: %w", err)
}
return nil
}
// ClearSatisfiedWants drops everything already owned.
func (s *Store) ClearSatisfiedWants(ctx context.Context) error {
if err := s.db.Queries.DeleteSatisfiedDownloadWants(ctx); err != nil {
return fmt.Errorf("clear satisfied download wants: %w", err)
}
return nil
}
// wantRowsToWants decodes a slice of stored rows.
func wantRowsToWants(rows []sqlcgen.DownloadWant) []Want {
out := make([]Want, 0, len(rows))
for _, r := range rows {
out = append(out, wantRowToWant(r))
}
return out
}
// wantRowToWant decodes a stored want row. A malformed external-ID
// blob yields an empty map rather than an error: losing the link to a
// Lidarr row is recoverable on the next sync, making the wanted list
// unreadable is not.
func wantRowToWant(r sqlcgen.DownloadWant) Want {
external := map[string]string{}
_ = json.Unmarshal([]byte(r.ExternalIds), &external)
return Want{
ID: r.ID,
MBID: r.Mbid,
Entity: Entity(r.Entity),
LibraryID: r.LibraryID,
Artist: r.Artist,
Title: r.Title,
Scope: WantScope(r.Scope),
Secondary: r.Secondary != 0,
State: WantState(r.State),
ParentID: r.ParentID.Int64,
Attempts: int(r.Attempts),
LastError: r.LastError,
LastTriedAt: r.LastTriedAt.Time,
NextTryAt: r.NextTryAt.Time,
ExternalIDs: external,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}