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
+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.