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
530 lines
13 KiB
Go
530 lines
13 KiB
Go
package download
|
|
|
|
import (
|
|
"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. They sum to 1.0 along with weightSizeFit below.
|
|
const (
|
|
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
|
|
// 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 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(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)
|
|
|
|
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,
|
|
) QualityScore {
|
|
q := QualityScore{
|
|
Health: clamp01(c.Health),
|
|
Priority: clamp01(float64(priority) / 100.0),
|
|
SizeFit: prefs.sizeFit(c.TotalSize),
|
|
}
|
|
|
|
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)
|
|
|
|
q.Overall = weightFormat*q.FormatRank +
|
|
weightBitrate*q.Bitrate +
|
|
weightHealth*q.Health +
|
|
weightPriority*q.Priority +
|
|
weightSizeFit*q.SizeFit
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// AutoPickable reports whether a ranked list has a clear enough winner
|
|
// 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(dl Download, ranked []Candidate, prefs AutoDownloadPrefs) bool {
|
|
const (
|
|
minMatch = 0.85
|
|
minQuality = 0.5
|
|
minLead = 0.08
|
|
)
|
|
|
|
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 request list, where nobody is watching.
|
|
if len(dl.Expected) == 0 {
|
|
return false
|
|
}
|
|
|
|
// 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(eligible) > 1 && best.Score-eligible[1].Score < minLead {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// 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))
|
|
}
|