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
766 lines
22 KiB
Go
766 lines
22 KiB
Go
package download
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strings"
|
||
|
||
"yellowjacket/backend/autotag"
|
||
)
|
||
|
||
// Ranking keeps two questions apart:
|
||
//
|
||
// match — is this the release the user asked for?
|
||
// quality — is it a good copy of it?
|
||
//
|
||
// They are reported separately because they fail differently and trade
|
||
// off against each other: a flawless FLAC of the wrong album is useless,
|
||
// a 128kbps rip of the right one is merely disappointing, and only the
|
||
// user knows which they will accept. A single blended number cannot be
|
||
// explained, and the review UI has to explain itself.
|
||
|
||
// Ranking weights. Match dominates, because a wrong album at any
|
||
// bitrate is a failed download.
|
||
const (
|
||
weightMatch = 0.72
|
||
weightQuality = 0.28
|
||
)
|
||
|
||
// Match sub-weights.
|
||
const (
|
||
weightTitleFit = 0.40
|
||
weightCompleteness = 0.30
|
||
weightAlbumFit = 0.18
|
||
weightArtistFit = 0.12
|
||
)
|
||
|
||
// Quality sub-weights. Each set sums to 1.0.
|
||
//
|
||
// There are two of them because a stated preference changes what the
|
||
// other numbers are *for*. `formatRank` and `bitrateScore` are the
|
||
// app guessing at how good a copy is — FLAC over MP3, 320 over 128 —
|
||
// and that guess exists precisely because the user has not said. Once
|
||
// they have, the guess should not outvote them: with the old single set
|
||
// a preference of 320 kbps moved a candidate's score by at most 0.05
|
||
// against the 0.42 riding on format, so asking for 320 and being handed
|
||
// a FLAC every time was the *designed* behaviour. That is the same
|
||
// fault the megabyte window had — a preference the user can express and
|
||
// the ranking can ignore.
|
||
const (
|
||
weightFormat = 0.42
|
||
weightBitrate = 0.23
|
||
weightHealth = 0.20
|
||
weightPriority = 0.10
|
||
weightBitrateFit = 0.05
|
||
)
|
||
|
||
// Quality sub-weights when the user has named a preferred bitrate.
|
||
// The weight comes off format and bitrate — the two proxies the
|
||
// preference replaces — and health and priority are untouched, since
|
||
// neither is a stand-in for anything the user just said.
|
||
const (
|
||
statedWeightFormat = 0.20
|
||
statedWeightBitrate = 0.10
|
||
statedWeightHealth = 0.20
|
||
statedWeightPriority = 0.10
|
||
statedWeightBitrateFit = 0.40
|
||
)
|
||
|
||
// qualityWeights picks the set, in the order scoreQuality applies them.
|
||
func qualityWeights(p AutoDownloadPrefs) (
|
||
format, bitrate, health, priority, fit float64,
|
||
) {
|
||
if p.PreferredKbps > 0 {
|
||
return statedWeightFormat,
|
||
statedWeightBitrate,
|
||
statedWeightHealth,
|
||
statedWeightPriority,
|
||
statedWeightBitrateFit
|
||
}
|
||
|
||
return weightFormat,
|
||
weightBitrate,
|
||
weightHealth,
|
||
weightPriority,
|
||
weightBitrateFit
|
||
}
|
||
|
||
// unanchoredCap bounds the match score of a free-text request. Without
|
||
// an MBID there is no tracklist to be right about, so a confident-
|
||
// 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 bitrate window, no
|
||
// size ceiling and no format restriction.
|
||
//
|
||
// **The window is a rate, not a size.** It used to be three numbers in
|
||
// megabytes, which cannot mean anything on their own: 300 MB is a
|
||
// generous FLAC single and a suspiciously small boxset, and the user
|
||
// setting the number has no idea which release the pipeline will
|
||
// eventually apply it to. A bitrate is the same statement normalised
|
||
// by how long the music is, so one number holds across a 9-minute EP
|
||
// and a 3-hour opera — and it is the unit the thing being described is
|
||
// actually measured in. The runtime is known for every request
|
||
// auto-pick can act on (`Download.Expected` carries per-track lengths,
|
||
// and an anchored request is the only kind that reaches here), so this
|
||
// costs no extra lookup.
|
||
type AutoDownloadPrefs struct {
|
||
// MinKbps and MaxKbps bound the average bitrate 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 96 kbps rip of the right album is not a worse copy the
|
||
// user might accept, it is one they said not to take unattended.
|
||
//
|
||
// For reference: 320 is the top of MP3, ~500–1000 is FLAC depending
|
||
// on the material, and anything under ~128 is a transcode.
|
||
MinKbps int `json:"minKbps"`
|
||
MaxKbps int `json:"maxKbps"`
|
||
|
||
// PreferredKbps nudges the score toward a target rate within the
|
||
// window, and breaks the tie when several candidates are equally
|
||
// good matches. Zero disables the nudge; bitrateFit then returns a
|
||
// neutral value that does not affect ranking.
|
||
PreferredKbps int `json:"preferredKbps"`
|
||
|
||
// MaxSizeMB is a hard ceiling on the whole candidate, and it is
|
||
// deliberately still a size. It answers a different question from
|
||
// the window above — not "is this the quality I want" but "is this
|
||
// going to fill the disk" — and it has to hold even for a candidate
|
||
// whose bitrate cannot be worked out, which is exactly the shape a
|
||
// mislabelled boxset arrives in. Zero means no ceiling.
|
||
MaxSizeMB int `json:"maxSizeMb"`
|
||
|
||
// 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: inside the bitrate window and the size ceiling (when
|
||
// set) and, when a format list is given, every audio file in an
|
||
// allowed format.
|
||
//
|
||
// `runtimeMillis` is how long the requested release is, and 0 means
|
||
// nobody knows. An unknown runtime **passes** the bitrate window
|
||
// rather than failing it: the window is a statement about quality, and
|
||
// refusing everything the moment a tracklist is missing a length would
|
||
// turn a gap in MusicBrainz into a silent embargo. The size ceiling
|
||
// still applies, which is why it exists separately.
|
||
func (p AutoDownloadPrefs) eligible(c Candidate, runtimeMillis int64) bool {
|
||
const bytesPerMB = 1 << 20
|
||
|
||
if p.MaxSizeMB > 0 && c.TotalSize > int64(p.MaxSizeMB)*bytesPerMB {
|
||
return false
|
||
}
|
||
|
||
if kbps := candidateKbps(c, runtimeMillis); kbps > 0 {
|
||
if p.MinKbps > 0 && kbps < float64(p.MinKbps) {
|
||
return false
|
||
}
|
||
|
||
if p.MaxKbps > 0 && kbps > float64(p.MaxKbps) {
|
||
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,
|
||
runtimeMillis int64,
|
||
) []Candidate {
|
||
out := make([]Candidate, 0, len(ranked))
|
||
|
||
for _, c := range ranked {
|
||
if p.eligible(c, runtimeMillis) {
|
||
out = append(out, c)
|
||
}
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
// bitrateFit scores how close a candidate's average bitrate is to
|
||
// PreferredKbps, falling off linearly as it doubles or halves away
|
||
// from it.
|
||
//
|
||
// The range is **0.5 to 1.0, not 0 to 1**, and the floor is the point.
|
||
// This carries 0.40 of the quality score once a preference is set, so a
|
||
// span down to zero would let a preference of 320 kbps push a perfectly
|
||
// good FLAC under `minQuality` and out of auto-pick altogether —
|
||
// turning "I like 320" into "never take anything else", silently. A
|
||
// preference may promote the copy that matches it; it may not
|
||
// disqualify the others. That is what `MinKbps`/`MaxKbps` are for, and
|
||
// they say so out loud.
|
||
//
|
||
// Returns the neutral floor when no preference is set or the rate
|
||
// cannot be worked out, so neither an absent preference nor an absent
|
||
// runtime biases ranking.
|
||
func (p AutoDownloadPrefs) bitrateFit(
|
||
c Candidate,
|
||
runtimeMillis int64,
|
||
) float64 {
|
||
const (
|
||
neutral = 0.5
|
||
span = 0.5
|
||
)
|
||
|
||
if p.PreferredKbps <= 0 {
|
||
return neutral
|
||
}
|
||
|
||
kbps := candidateKbps(c, runtimeMillis)
|
||
if kbps <= 0 {
|
||
return neutral
|
||
}
|
||
|
||
ratio := kbps / float64(p.PreferredKbps)
|
||
if ratio < 1 {
|
||
ratio = 1 / ratio
|
||
}
|
||
|
||
// ratio is now >= 1: 1.0 is an exact match, 2.0 is double or half
|
||
// the preferred rate, where the closeness term reaches 0.
|
||
return neutral + span*clamp01(1-(ratio-1))
|
||
}
|
||
|
||
// candidateKbps is a candidate's average audio bitrate, or 0 when it
|
||
// cannot be worked out.
|
||
//
|
||
// Two sources, in this order, and the order matters:
|
||
//
|
||
// - **Derived from bytes over runtime**, which is the honest one. It
|
||
// covers lossless (where a stated bitrate rarely exists), it cannot
|
||
// be lied to by a filename, and it is what the user's window means.
|
||
// Only the *audio* files count: cover scans and a log file are not
|
||
// part of the bitrate, and a folder with 30 MB of artwork would
|
||
// otherwise read as a better rip than the same music without it.
|
||
// - **The mean stated bitrate**, when the runtime is unknown. Weaker
|
||
// — a provider that parses it from an MP3 header states it and one
|
||
// that guesses from the filename also "states" it — but a number
|
||
// from the file itself beats no number at all.
|
||
func candidateKbps(c Candidate, runtimeMillis int64) float64 {
|
||
const bitsPerByte = 8
|
||
|
||
audio := c.AudioFiles()
|
||
if len(audio) == 0 {
|
||
return 0
|
||
}
|
||
|
||
if runtimeMillis > 0 {
|
||
var bytes int64
|
||
for _, f := range audio {
|
||
bytes += f.Size
|
||
}
|
||
|
||
if bytes > 0 {
|
||
// bytes×8 bits over seconds, expressed in kbps: the two
|
||
// factors of 1000 (millis→seconds, bits→kilobits) cancel.
|
||
return float64(bytes) * bitsPerByte /
|
||
float64(runtimeMillis)
|
||
}
|
||
}
|
||
|
||
var (
|
||
sum int
|
||
count int
|
||
)
|
||
|
||
for _, f := range audio {
|
||
if f.Bitrate > 0 {
|
||
sum += f.Bitrate
|
||
count++
|
||
}
|
||
}
|
||
|
||
if count == 0 {
|
||
return 0
|
||
}
|
||
|
||
return float64(sum) / float64(count)
|
||
}
|
||
|
||
// runtimeMillis is how long the requested release is, summed over its
|
||
// expected tracklist. Zero when the tracklist is absent or carries no
|
||
// lengths, which is what every caller here treats as "unknown".
|
||
func (d Download) runtimeMillis() int64 {
|
||
var total int64
|
||
for _, t := range d.Expected {
|
||
total += t.LengthMillis
|
||
}
|
||
|
||
return total
|
||
}
|
||
|
||
// Score fills a candidate's Match, Quality and Score fields.
|
||
func Score(dl Download, c Candidate, priority int, prefs AutoDownloadPrefs) Candidate {
|
||
c.Files = AnnotateFiles(c.Files)
|
||
|
||
audio := c.AudioFiles()
|
||
|
||
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(dl, c, audio, titleFit)
|
||
c.Quality = scoreQuality(
|
||
c, audio, priority, prefs, dl.runtimeMillis(),
|
||
)
|
||
|
||
c.Score = weightMatch*c.Match.Overall + weightQuality*c.Quality.Overall
|
||
|
||
return c
|
||
}
|
||
|
||
// scoreMatch answers whether this candidate is the requested release.
|
||
func scoreMatch(
|
||
dl Download,
|
||
c Candidate,
|
||
audio []CandidateFile,
|
||
titleFit float64,
|
||
) MatchScore {
|
||
m := MatchScore{
|
||
Anchored: dl.Anchored(),
|
||
TitleFit: titleFit,
|
||
}
|
||
|
||
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:
|
||
// providers vary in which is meaningful.
|
||
folder := ""
|
||
if len(audio) > 0 {
|
||
folder = ParsePath(audio[0].Path).Folder
|
||
}
|
||
|
||
m.AlbumFit = math.Max(
|
||
autotag.TitleSimilarity(dl.Album, c.Title),
|
||
autotag.TitleSimilarity(dl.Album, folder),
|
||
)
|
||
|
||
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(dl.Expected) == 0 {
|
||
m.Overall = 0.55*m.AlbumFit + 0.45*m.ArtistFit
|
||
} else {
|
||
m.Overall = weightTitleFit*m.TitleFit +
|
||
weightCompleteness*m.Completeness +
|
||
weightAlbumFit*m.AlbumFit +
|
||
weightArtistFit*m.ArtistFit
|
||
}
|
||
|
||
if !m.Anchored {
|
||
m.Overall = math.Min(m.Overall, unanchoredCap)
|
||
}
|
||
|
||
return m
|
||
}
|
||
|
||
// artistFit compares the requested artist against the candidate's
|
||
// artist field, its title, and the path of its first audio file, taking
|
||
// the best. Providers disagree about where the artist name lands.
|
||
func artistFit(want string, c Candidate) float64 {
|
||
if strings.TrimSpace(want) == "" {
|
||
return 0.5
|
||
}
|
||
|
||
best := autotag.TitleSimilarity(want, c.Artist)
|
||
|
||
if s := autotag.TitleSimilarity(want, c.Title); s > best {
|
||
best = s
|
||
}
|
||
|
||
// A path containing the artist name anywhere is weak but real
|
||
// evidence — most folders are "Artist - Album".
|
||
norm := autotag.Normalize(want)
|
||
if norm != "" {
|
||
for _, f := range c.Files {
|
||
if strings.Contains(autotag.Normalize(f.Path), norm) {
|
||
if best < 0.8 {
|
||
best = 0.8
|
||
}
|
||
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
return best
|
||
}
|
||
|
||
// completeness scores audio file count against the expected track
|
||
// count. Extra files are penalized far more gently than missing ones:
|
||
// a folder with bonus tracks or a stray intro is still the album, while
|
||
// a folder missing half the tracks is not.
|
||
func completeness(got, want int) float64 {
|
||
if want == 0 {
|
||
if got > 0 {
|
||
return 0.5
|
||
}
|
||
|
||
return 0
|
||
}
|
||
|
||
if got == 0 {
|
||
return 0
|
||
}
|
||
|
||
if got >= want {
|
||
extra := float64(got-want) / float64(want)
|
||
|
||
return math.Max(0.75, 1.0-0.25*extra)
|
||
}
|
||
|
||
return float64(got) / float64(want)
|
||
}
|
||
|
||
// scoreQuality answers whether this is a good copy.
|
||
func scoreQuality(
|
||
c Candidate,
|
||
audio []CandidateFile,
|
||
priority int,
|
||
prefs AutoDownloadPrefs,
|
||
runtimeMillis int64,
|
||
) QualityScore {
|
||
q := QualityScore{
|
||
Health: clamp01(c.Health),
|
||
Priority: clamp01(float64(priority) / 100.0),
|
||
BitrateFit: prefs.bitrateFit(c, runtimeMillis),
|
||
}
|
||
|
||
if len(audio) == 0 {
|
||
return q
|
||
}
|
||
|
||
// Format: score the worst file, not the average. A folder that is
|
||
// mostly FLAC with three MP3s transcoded in is a worse copy than
|
||
// its average suggests, and that is exactly what the user would
|
||
// want flagged.
|
||
worst := 1.0
|
||
first := audio[0].Format
|
||
|
||
for _, f := range audio {
|
||
if r := formatRank(f.Format); r < worst {
|
||
worst = r
|
||
}
|
||
|
||
if f.Format != first {
|
||
q.Mixed = true
|
||
}
|
||
}
|
||
|
||
q.FormatRank = worst
|
||
q.Bitrate = bitrateScore(audio)
|
||
|
||
wFormat, wBitrate, wHealth, wPriority, wFit := qualityWeights(prefs)
|
||
|
||
q.Overall = wFormat*q.FormatRank +
|
||
wBitrate*q.Bitrate +
|
||
wHealth*q.Health +
|
||
wPriority*q.Priority +
|
||
wFit*q.BitrateFit
|
||
|
||
if q.Mixed {
|
||
q.Overall *= 0.9
|
||
}
|
||
|
||
return q
|
||
}
|
||
|
||
// formatRank scores a format on its own terms, in 0..1. Lossless
|
||
// formats top out; lossy formats sit below and are further separated by
|
||
// bitrate. Formats the player cannot decode are penalized but not
|
||
// zeroed — the user may be acquiring them deliberately.
|
||
func formatRank(f Format) float64 {
|
||
base := 0.0
|
||
|
||
switch f {
|
||
case FormatFLAC:
|
||
base = 1.0
|
||
case FormatALAC:
|
||
base = 0.95
|
||
case FormatWAV:
|
||
base = 0.85 // lossless, but untaggable and huge
|
||
case FormatMP3:
|
||
base = 0.6
|
||
case FormatAAC, FormatOpus:
|
||
base = 0.6
|
||
case FormatOGG:
|
||
base = 0.55
|
||
case FormatWMA:
|
||
base = 0.3
|
||
case FormatUnknown:
|
||
base = 0.2
|
||
default:
|
||
base = 0.2
|
||
}
|
||
|
||
if !f.Supported() && f != FormatUnknown {
|
||
base *= 0.8
|
||
}
|
||
|
||
return base
|
||
}
|
||
|
||
// bitrateScore maps the mean stated bitrate of lossy files onto 0..1.
|
||
// Lossless files score 1.0 and are excluded from the mean. Returns a
|
||
// neutral 0.5 when nothing states a bitrate, which is the common case
|
||
// for Soulseek results.
|
||
func bitrateScore(audio []CandidateFile) float64 {
|
||
var (
|
||
sum float64
|
||
count int
|
||
)
|
||
|
||
for _, f := range audio {
|
||
if f.Format.Lossless() {
|
||
sum += 1.0
|
||
count++
|
||
|
||
continue
|
||
}
|
||
|
||
if f.Bitrate == 0 {
|
||
continue
|
||
}
|
||
|
||
sum += lossyBitrateScore(f.Bitrate)
|
||
count++
|
||
}
|
||
|
||
if count == 0 {
|
||
return 0.5
|
||
}
|
||
|
||
return sum / float64(count)
|
||
}
|
||
|
||
// lossyBitrateScore maps kbps onto 0..1 with the knee where it belongs
|
||
// perceptually: the gap between 128 and 192 matters much more than the
|
||
// gap between 256 and 320.
|
||
func lossyBitrateScore(kbps int) float64 {
|
||
switch {
|
||
case kbps >= 320:
|
||
return 1.0
|
||
case kbps >= 256:
|
||
return 0.9
|
||
case kbps >= 224:
|
||
return 0.82
|
||
case kbps >= 192:
|
||
return 0.72
|
||
case kbps >= 160:
|
||
return 0.55
|
||
case kbps >= 128:
|
||
return 0.4
|
||
case kbps >= 96:
|
||
return 0.2
|
||
default:
|
||
return 0.1
|
||
}
|
||
}
|
||
|
||
// Rank scores every candidate and returns them best-first. Ties break
|
||
// on match, then on provider priority, then on file count, so the order
|
||
// is stable across runs rather than map-iteration dependent.
|
||
func Rank(
|
||
dl Download,
|
||
candidates []Candidate,
|
||
priority func(providerID int64) int,
|
||
prefs AutoDownloadPrefs,
|
||
) []Candidate {
|
||
out := make([]Candidate, 0, len(candidates))
|
||
|
||
for _, c := range candidates {
|
||
p := 50
|
||
if priority != nil {
|
||
p = priority(c.ProviderID)
|
||
}
|
||
|
||
out = append(out, Score(dl, c, p, prefs))
|
||
}
|
||
|
||
sort.SliceStable(out, func(i, j int) bool {
|
||
if out[i].Score != out[j].Score {
|
||
return out[i].Score > out[j].Score
|
||
}
|
||
|
||
if out[i].Match.Overall != out[j].Match.Overall {
|
||
return out[i].Match.Overall > out[j].Match.Overall
|
||
}
|
||
|
||
// Closest to the preferred bitrate wins the tie.
|
||
//
|
||
// This is what decides which copy is taken now that auto-pick
|
||
// no longer requires the winner to be clear of the field: when
|
||
// several candidates are equally good matches of equal overall
|
||
// quality, the one the user said they wanted the shape of is
|
||
// the answer, ahead of provider priority. With no preference
|
||
// set every BitrateFit is the same neutral value and this
|
||
// falls through, exactly as before.
|
||
if out[i].Quality.BitrateFit != out[j].Quality.BitrateFit {
|
||
return out[i].Quality.BitrateFit > out[j].Quality.BitrateFit
|
||
}
|
||
|
||
if out[i].Quality.Priority != out[j].Quality.Priority {
|
||
return out[i].Quality.Priority > out[j].Quality.Priority
|
||
}
|
||
|
||
return len(out[i].Files) > len(out[j].Files)
|
||
})
|
||
|
||
return out
|
||
}
|
||
|
||
// Auto-pick gates. Named rather than inlined because AutoPickVeto
|
||
// reports which of them refused, and a number in a sentence the user
|
||
// reads should be the same number the decision used.
|
||
const (
|
||
minMatch = 0.85
|
||
minQuality = 0.5
|
||
)
|
||
|
||
// AutoPickable reports whether a ranked list has a candidate worth
|
||
// grabbing without asking: an anchored request with a tracklist behind
|
||
// it, and a candidate that clears the match and quality bars inside the
|
||
// user's guardrails.
|
||
//
|
||
// **It does not require the winner to be better than the runner-up.**
|
||
// It used to demand 0.08 of daylight on the combined score, which meant
|
||
// the check 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 auto-pick 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 one condition under which that question matters
|
||
// least. A candidate does not need to be the best one, only one that
|
||
// meets the criteria; where several do, `Rank` puts the one closest to
|
||
// the preferred bitrate first.
|
||
func AutoPickable(dl Download, ranked []Candidate, prefs AutoDownloadPrefs) bool {
|
||
return AutoPickVeto(dl, ranked, prefs) == ""
|
||
}
|
||
|
||
// AutoPickVeto returns the reason auto-pick declined, or "" when it
|
||
// would go ahead.
|
||
//
|
||
// It exists because "it rejected all of them" was indistinguishable
|
||
// from "it found nothing good". The request list's message was built
|
||
// from `ranked[0]` — the best candidate *before* the size and format
|
||
// guardrails, and before the lead check — so a request refused because
|
||
// the user's maximum size excluded every copy, or because three equally
|
||
// good copies were found, reported "best of 12 found is not a confident
|
||
// enough match (match 96%, quality 88%)". Numbers that clear both
|
||
// thresholds, beside a refusal, is a message that teaches the user the
|
||
// matcher is broken. Each gate names itself now.
|
||
func AutoPickVeto(
|
||
dl Download,
|
||
ranked []Candidate,
|
||
prefs AutoDownloadPrefs,
|
||
) string {
|
||
if len(ranked) == 0 {
|
||
return "nothing found"
|
||
}
|
||
|
||
if !dl.Anchored() {
|
||
return "the request is free text, so there is no release to be right about"
|
||
}
|
||
|
||
// 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 request list, where nobody is watching.
|
||
if len(dl.Expected) == 0 {
|
||
return "no tracklist for this release is known yet, so a candidate cannot be checked against it"
|
||
}
|
||
|
||
// The guardrails apply before the match and quality checks: a
|
||
// candidate outside the allowed bitrate, 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" either.
|
||
eligible := prefs.filter(ranked, dl.runtimeMillis())
|
||
if len(eligible) == 0 {
|
||
return fmt.Sprintf(
|
||
"all %d found are outside the auto-download bitrate, size or format limits",
|
||
len(ranked),
|
||
)
|
||
}
|
||
|
||
best := eligible[0]
|
||
|
||
if best.Match.Overall < minMatch {
|
||
return fmt.Sprintf(
|
||
"best of %d found matches this release only %.0f%% (needs %.0f%%)",
|
||
len(ranked),
|
||
best.Match.Overall*100, //nolint:mnd // percent
|
||
minMatch*100, //nolint:mnd // percent
|
||
)
|
||
}
|
||
|
||
if best.Quality.Overall < minQuality {
|
||
return fmt.Sprintf(
|
||
"best of %d found is the right release but scores %.0f%% on quality (needs %.0f%%)",
|
||
len(ranked),
|
||
best.Quality.Overall*100, //nolint:mnd // percent
|
||
minQuality*100, //nolint:mnd // percent
|
||
)
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
// mergeMatched copies MatchedTo assignments from the audio-only slice
|
||
// back onto the full file list.
|
||
func mergeMatched(all, matched []CandidateFile) []CandidateFile {
|
||
if len(matched) == 0 {
|
||
return all
|
||
}
|
||
|
||
byPath := make(map[string]int, len(matched))
|
||
for _, m := range matched {
|
||
byPath[m.Path] = m.MatchedTo
|
||
}
|
||
|
||
out := make([]CandidateFile, len(all))
|
||
copy(out, all)
|
||
|
||
for i := range out {
|
||
if pos, ok := byPath[out[i].Path]; ok {
|
||
out[i].MatchedTo = pos
|
||
}
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
// clamp01 bounds a value to 0..1.
|
||
func clamp01(v float64) float64 {
|
||
return math.Max(0, math.Min(1, v))
|
||
}
|