Three faults, one subsystem, and the middle one is why a request that looked obviously satisfiable came back refused. **The guardrails were in megabytes, which cannot mean anything.** 300 MB is a generous FLAC single and a suspiciously small boxset, and whoever fills the field in has no idea which release the pipeline will apply it to. `MinKbps`/`MaxKbps`/`PreferredKbps` are the same statement divided by how long the music is, so one number holds across a nine-minute EP and a three-hour opera. The runtime comes from `Download.Expected`, which every anchored request already carries, so this costs no lookup; the rate is audio bytes over that, falling back to the mean stated per-file bitrate when the runtime is unknown. Artwork is excluded from the numerator, or a folder with 30 MB of scans reads as a better rip. An unknown runtime *passes* the window rather than failing it: the window is a statement about quality, and refusing everything the moment MusicBrainz is missing a track length would be a silent embargo. `MaxFileSizeMB` survives as a separate ceiling, still in megabytes on purpose -- it is a question about disk space, and it has to apply to a candidate whose bitrate cannot be worked out at all. **Auto-pick required daylight over the runner-up**, 0.08 on the combined score, and so fired hardest in the case it was never written for: a popular album turns up five *correct* copies, all matching the tracklist at 95%+ and differing only in format and seeders, their scores land within a point of each other, and it refused forever on the grounds that the choice was the user's. It was not. There was no question about what to fetch, only about which copy -- and abundance is the condition under which that matters least. A candidate no longer has to beat the field, only clear the bars on its own terms; where several do, ranking puts the one closest to the preferred bitrate first. That tie-break needed the preference to carry weight or it would have been decorative in a new unit: `BitrateFit` was 0.05 against format's 0.42, so asking for 320 and being handed a FLAC every time was the designed behaviour. When a preference is set the weights shift to fit 0.40 / format 0.20 / bitrate 0.10, taking it off the two heuristics that exist as stand-ins for the preference the user has now given. Health and priority are untouched. And the fit spans 0.5 to 1.0 rather than 0 to 1, so a preference can promote the copy that matches it and can never push the others under `minQuality` -- turning "I like 320" into "never take anything else" silently is what `MinKbps`/`MaxKbps` are for, out loud. **And a refusal quoted numbers that passed.** The request list built its message from `ranked[0]` -- the best candidate *before* the guardrails and before the lead check -- so a request killed by the size window, or by having too many good copies, reported "best of 12 found is not a confident enough match (match 96%, quality 88%)". `AutoPickVeto` names the gate that actually refused, and `AutoPickable` is that returning empty. Existing configs: the old `MinFileSizeMB`/`PreferredFileSizeMB` are not migrated. A number meaning "300 MB" cannot be reinterpreted as a rate without knowing the album it was aimed at, so carrying it over would be inventing an intent nobody expressed. Those two fall back to no window, which is the permissive default and what a fresh install gets; `MaxFileSizeMB` carries over unchanged, because a ceiling on bytes still means exactly what it did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
399 lines
14 KiB
Go
399 lines
14 KiB
Go
// Package download acquires music from user-configured external
|
|
// services and imports it into the library.
|
|
//
|
|
// The services users connect are not the same kind of thing: some
|
|
// search, some move bytes, some are whole automation systems we hand a
|
|
// request to. Rather than one interface every adapter half-implements,
|
|
// a provider fills one or more of three roles — Searcher, Transporter,
|
|
// Delegator — and declares which in its Caps. The pipeline composes
|
|
// them: a search-only provider (Prowlarr) is paired with a transport
|
|
// (qBittorrent, SABnzbd) by protocol at grab time, while providers that
|
|
// do both (slskd, yt-dlp) pair with themselves.
|
|
//
|
|
// Nothing here downloads into the library. Grabs land in a staging
|
|
// directory, are verified and tagged against the release the user
|
|
// actually asked for, and only then move into library paths.
|
|
package download
|
|
|
|
import (
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Kind identifies a provider implementation. It is stored in the
|
|
// database and used to look up the constructor in the registry, so
|
|
// values are stable strings and never renamed.
|
|
type Kind string
|
|
|
|
// Provider kinds.
|
|
const (
|
|
KindSlskd Kind = "slskd"
|
|
KindYtDlp Kind = "yt-dlp"
|
|
KindLidarr Kind = "lidarr"
|
|
KindProwlarr Kind = "prowlarr"
|
|
KindQBittorrent Kind = "qbittorrent"
|
|
KindSABnzbd Kind = "sabnzbd"
|
|
|
|
// KindFake is an in-memory provider used by tests. It is never
|
|
// offered in the UI.
|
|
KindFake Kind = "fake"
|
|
)
|
|
|
|
// Protocol is how a candidate's bytes are moved. Search-only providers
|
|
// report it so the pipeline can pick a compatible transport; providers
|
|
// that transport their own results use ProtocolDirect.
|
|
type Protocol string
|
|
|
|
// Transport protocols.
|
|
const (
|
|
// ProtocolDirect means the finding provider also does the fetch.
|
|
ProtocolDirect Protocol = "direct"
|
|
ProtocolTorrent Protocol = "torrent"
|
|
ProtocolUsenet Protocol = "usenet"
|
|
)
|
|
|
|
// Caps declares which roles a provider fills and which optional
|
|
// behaviours it supports. The frontend renders controls from this
|
|
// rather than switching on Kind, so a provider that gains resume
|
|
// support later needs no frontend change.
|
|
type Caps struct {
|
|
// Roles.
|
|
CanSearch bool `json:"canSearch"`
|
|
CanTransport bool `json:"canTransport"`
|
|
CanDelegate bool `json:"canDelegate"`
|
|
|
|
// CanList marks a provider that keeps a persistent wanted list of
|
|
// its own, which the reconciler mirrors this app's list into.
|
|
CanList bool `json:"canList"`
|
|
|
|
// Optional behaviours.
|
|
CanResume bool `json:"canResume"`
|
|
CanCancel bool `json:"canCancel"`
|
|
ReportsSize bool `json:"reportsSize"`
|
|
|
|
// Protocols this provider can transport. Empty for providers that
|
|
// only fetch their own search results.
|
|
Transports []Protocol `json:"transports"`
|
|
}
|
|
|
|
// Handles reports whether the provider can transport the given protocol.
|
|
func (c Caps) Handles(p Protocol) bool {
|
|
return slices.Contains(c.Transports, p)
|
|
}
|
|
|
|
// 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 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"`
|
|
|
|
// 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 download came from, for the downloads
|
|
// list. Empty means "manual".
|
|
Source string `json:"source,omitempty"`
|
|
|
|
// Display and query text. Artist/Album are what searches are built
|
|
// from; Query overrides them when the user typed something raw.
|
|
Artist string `json:"artist"`
|
|
Album string `json:"album"`
|
|
Query string `json:"query,omitempty"`
|
|
|
|
// Expected is the tracklist the anchor resolves to, used for
|
|
// completeness scoring and for the autotag match at import. Empty
|
|
// for free-text downloads.
|
|
Expected []ExpectedTrack `json:"expected,omitempty"`
|
|
|
|
// LibraryID is the library imported files belong to.
|
|
LibraryID int64 `json:"libraryId"`
|
|
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Album titles routinely start with the artist name — self-titled
|
|
// albums ("Boston" / "Boston") and titles like "Blank Banshee 0" both
|
|
// do — so naively concatenating Artist and Album would search for
|
|
// "Blank Banshee Blank Banshee 0". That repeated term is enough to
|
|
// 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 (d Download) SearchText() string {
|
|
if d.Query != "" {
|
|
return d.Query
|
|
}
|
|
|
|
if d.Artist != "" && albumLeadsWithArtist(d.Artist, d.Album) {
|
|
return strings.TrimSpace(d.Album)
|
|
}
|
|
|
|
return strings.TrimSpace(d.Artist + " " + d.Album)
|
|
}
|
|
|
|
// albumLeadsWithArtist reports whether album starts with artist as a
|
|
// whole word, case-insensitively, so it is safe to drop the artist
|
|
// from a combined query without losing a real search term. A plain
|
|
// substring check would misfire on cases like artist "Air" against
|
|
// album "Repair".
|
|
func albumLeadsWithArtist(artist, album string) bool {
|
|
a, b := strings.ToLower(strings.TrimSpace(artist)), strings.ToLower(strings.TrimSpace(album))
|
|
if a == "" || !strings.HasPrefix(b, a) {
|
|
return false
|
|
}
|
|
|
|
rest := b[len(a):]
|
|
|
|
return rest == "" || !isWordChar(rune(rest[0]))
|
|
}
|
|
|
|
// isWordChar reports whether r continues a word for the purposes of
|
|
// albumLeadsWithArtist's boundary check.
|
|
func isWordChar(r rune) bool {
|
|
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
|
|
}
|
|
|
|
// ExpectedTrack is one track of the release the user asked for.
|
|
type ExpectedTrack struct {
|
|
Position int `json:"position"`
|
|
DiscNumber int `json:"discNumber"`
|
|
Title string `json:"title"`
|
|
Artist string `json:"artist"`
|
|
LengthMillis int64 `json:"lengthMillis"`
|
|
}
|
|
|
|
// Candidate is one acquirable thing a provider found: a Soulseek user's
|
|
// folder, a torrent, a YouTube playlist. Providers fill the descriptive
|
|
// fields; the ranker fills Match, Quality and Score.
|
|
type Candidate struct {
|
|
// ID is unique within the provider that produced it, and is what
|
|
// gets handed back to Grab.
|
|
ID string `json:"id"`
|
|
ProviderID int64 `json:"providerId"`
|
|
Kind Kind `json:"kind"`
|
|
|
|
// Protocol determines which transport can fetch this.
|
|
Protocol Protocol `json:"protocol"`
|
|
|
|
// Descriptive.
|
|
Title string `json:"title"`
|
|
Artist string `json:"artist,omitempty"`
|
|
Origin string `json:"origin,omitempty"` // peer username, indexer name, channel
|
|
|
|
Files []CandidateFile `json:"files"`
|
|
TotalSize int64 `json:"totalSize"`
|
|
|
|
// Health is the provider's own availability signal, normalized to
|
|
// 0..1: seeder count for torrents, free upload slots and queue
|
|
// length for Soulseek. 0.5 when the provider has no signal.
|
|
Health float64 `json:"health"`
|
|
|
|
// Scores, filled by the ranker.
|
|
Match MatchScore `json:"match"`
|
|
Quality QualityScore `json:"quality"`
|
|
Score float64 `json:"score"`
|
|
|
|
// Payload is provider-private data needed to fetch this candidate
|
|
// (magnet URI, NZB URL, slskd file list). Never shown to the user.
|
|
Payload map[string]string `json:"-"`
|
|
}
|
|
|
|
// CandidateFile is one file inside a candidate. Soulseek and torrent
|
|
// results give paths and sizes but no tags, so Format and duration are
|
|
// inferred from the path and size where possible.
|
|
type CandidateFile struct {
|
|
Path string `json:"path"`
|
|
Size int64 `json:"size"`
|
|
Format Format `json:"format"`
|
|
Bitrate int `json:"bitrate,omitempty"` // kbps, 0 when unknown
|
|
IsAudio bool `json:"isAudio"`
|
|
MatchedTo int `json:"matchedTo,omitempty"` // expected track position
|
|
}
|
|
|
|
// Format is a normalized audio container/codec name.
|
|
type Format string
|
|
|
|
// Audio formats, ordered by the quality ranking in formatRank.
|
|
const (
|
|
FormatUnknown Format = ""
|
|
FormatFLAC Format = "flac"
|
|
FormatALAC Format = "alac"
|
|
FormatWAV Format = "wav"
|
|
FormatMP3 Format = "mp3"
|
|
FormatAAC Format = "aac"
|
|
FormatOGG Format = "ogg"
|
|
FormatOpus Format = "opus"
|
|
FormatWMA Format = "wma"
|
|
)
|
|
|
|
// Lossless reports whether the format preserves the source exactly.
|
|
func (f Format) Lossless() bool {
|
|
return f == FormatFLAC || f == FormatALAC || f == FormatWAV
|
|
}
|
|
|
|
// Supported reports whether the player can decode this format. Grabs
|
|
// of unsupported formats are still allowed — the user may want them —
|
|
// but they rank below playable ones.
|
|
func (f Format) Supported() bool {
|
|
switch f {
|
|
case FormatMP3, FormatFLAC, FormatOGG, FormatWAV:
|
|
return true
|
|
case FormatUnknown, FormatALAC, FormatAAC, FormatOpus, FormatWMA:
|
|
return false
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MatchScore answers "is this the release the user asked for?" It is
|
|
// deliberately separate from QualityScore: a perfect match at 128kbps
|
|
// and a mediocre match in FLAC are different failures, and collapsing
|
|
// them into one number makes the ranking impossible to explain.
|
|
type MatchScore struct {
|
|
// Overall is 0..1.
|
|
Overall float64 `json:"overall"`
|
|
|
|
TitleFit float64 `json:"titleFit"` // filenames vs expected titles
|
|
ArtistFit float64 `json:"artistFit"` // path/origin vs expected artist
|
|
AlbumFit float64 `json:"albumFit"` // folder name vs album title
|
|
Completeness float64 `json:"completeness"` // audio files vs expected count
|
|
|
|
// Anchored records whether an MBID drove this score. Unanchored
|
|
// matches are capped, because there is nothing to be right about.
|
|
Anchored bool `json:"anchored"`
|
|
}
|
|
|
|
// QualityScore answers "is this a good copy?".
|
|
type QualityScore struct {
|
|
// Overall is 0..1.
|
|
Overall float64 `json:"overall"`
|
|
|
|
FormatRank float64 `json:"formatRank"` // FLAC > V0 > 320 > lower
|
|
Bitrate float64 `json:"bitrate"`
|
|
Health float64 `json:"health"` // seeders, free slots
|
|
Priority float64 `json:"priority"` // user's per-provider preference
|
|
// BitrateFit is closeness to the preferred *rate*, which is what
|
|
// the auto-download window is expressed in. It replaced a
|
|
// `SizeFit` measured in megabytes: a size means nothing without
|
|
// knowing how long the music is, so the same number described a
|
|
// generous single and a suspiciously small boxset.
|
|
BitrateFit float64 `json:"bitrateFit"`
|
|
|
|
// Mixed marks a candidate whose files are not all the same format,
|
|
// which usually means a hand-assembled folder rather than a rip.
|
|
Mixed bool `json:"mixed"`
|
|
}
|
|
|
|
// AudioFiles returns only the audio entries of a candidate.
|
|
func (c Candidate) AudioFiles() []CandidateFile {
|
|
out := make([]CandidateFile, 0, len(c.Files))
|
|
|
|
for _, f := range c.Files {
|
|
if f.IsAudio {
|
|
out = append(out, f)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// State is the lifecycle position of a download item.
|
|
type State string
|
|
|
|
// Download item states. Searching through Importing are live;
|
|
// Complete, Cancelled and Failed are terminal.
|
|
const (
|
|
StateSearching State = "searching"
|
|
StateFound State = "found"
|
|
StateQueued State = "queued"
|
|
StateGrabbing State = "grabbing"
|
|
StateVerifying State = "verifying"
|
|
StateTagging State = "tagging"
|
|
StateImporting State = "importing"
|
|
StateComplete State = "complete"
|
|
StateCancelled State = "cancelled"
|
|
StateFailed State = "failed"
|
|
)
|
|
|
|
// IsTerminal reports whether the state means no further progress will
|
|
// happen without a new attempt.
|
|
func (s State) IsTerminal() bool {
|
|
return s == StateComplete || s == StateCancelled || s == StateFailed
|
|
}
|
|
|
|
// Progress is a transport's periodic report. Total is 0 when the
|
|
// provider cannot say how large the transfer is.
|
|
type Progress struct {
|
|
Current int64
|
|
Total int64
|
|
Phase string
|
|
}
|
|
|
|
// ProgressFunc receives transport progress. Implementations must
|
|
// tolerate being called from any goroutine and at high frequency.
|
|
type ProgressFunc func(Progress)
|
|
|
|
// Result is what a transport produced.
|
|
type Result struct {
|
|
// Dir is the staging directory the files landed in.
|
|
Dir string
|
|
|
|
// Files are absolute paths, all under Dir.
|
|
Files []string
|
|
|
|
// BytesTransferred is what actually moved, for reporting.
|
|
BytesTransferred int64
|
|
|
|
// Delegated marks a result produced by an external manager that has
|
|
// already imported the files into its own library. Files are then
|
|
// absolute paths outside staging, and the pipeline records them
|
|
// where they are instead of tagging and moving them.
|
|
Delegated bool
|
|
}
|
|
|
|
// DelegateStatus is a delegating manager's answer to "are we there
|
|
// yet?".
|
|
type DelegateStatus struct {
|
|
State State
|
|
|
|
// Progress is 0..1 when the manager reports it, -1 when it does not.
|
|
Progress float64
|
|
|
|
// ImportedPaths are files the manager has already placed on disk.
|
|
// A delegate that imports into its own library reports them here so
|
|
// the pipeline can reconcile rather than re-import.
|
|
ImportedPaths []string
|
|
|
|
Message string
|
|
}
|