Files
yellowjacket/backend/autotag/rank.go
T
2026-05-01 11:52:50 -04:00

186 lines
4.4 KiB
Go

package autotag
import (
"sort"
"strconv"
"strings"
)
// Release-level scoring weights. Aggregate track score is the
// dominant signal — the release-level signals are tie-breakers
// when the track alignment is roughly comparable.
const (
weightTrackAggregate = 0.70
weightTrackCountMatch = 0.15
weightReleaseMeta = 0.15 // Official + country averaged
// Country preference: a very mild nudge toward releases from
// the user's locale. Will become a config option in 012.
preferredCountry = "US"
)
// ScoreCandidate fills in c.Alignments, c.Score, c.Breakdown, and
// c.TrackCount for a single candidate against the given local
// tracks. The returned Candidate is safe to copy — no shared
// state with the caller's slice.
func ScoreCandidate(local []LocalTrack, c Candidate, localTrackCount int) Candidate {
c.Alignments = AlignTracks(local, c.Tracks)
var (
titleSum float64
lengthSum float64
counted int
)
for _, a := range c.Alignments {
if a.Status != AlignmentMatched && a.Status != AlignmentMismatched {
continue
}
counted++
titleSum += a.TitleScore
l := local[a.LocalIndex]
lengthSum += lengthScore(l.LengthMillis, a.CandidateLength)
}
titleAvg, lengthAvg := 0.0, 0.0
if counted > 0 {
titleAvg = titleSum / float64(counted)
lengthAvg = lengthSum / float64(counted)
}
// Aggregate track score: weighted title + length (renormalized
// so a perfect match scales to 1.0 regardless of the absolute
// weights), scaled by how many of our local tracks actually
// matched — extra or missing tracks punish proportionally.
coverage := 0.0
if len(local) > 0 {
coverage = float64(counted) / float64(len(local))
}
const trackWeightSum = weightTitle + weightLength
trackAgg := ((titleAvg*weightTitle + lengthAvg*weightLength) / trackWeightSum) * coverage
trackCountScore := trackCountMatch(len(c.Tracks), localTrackCount)
// Release-meta is just official-status + country preference,
// averaged. We used to mix in a year bonus too, but that
// compared candidate years against time.Now() — penalising
// every album that wasn't from this year, regardless of how
// well it matched the local files. See git history.
const metaTerms = 2.0
meta := (officialBonus(c.Status) + countryBonus(c.Country)) / metaTerms
c.Score = trackAgg*weightTrackAggregate +
trackCountScore*weightTrackCountMatch +
meta*weightReleaseMeta
c.Breakdown = ScoreBreakdown{
TitleAvg: titleAvg,
LengthAvg: lengthAvg,
TrackCountFit: trackCountScore,
ReleaseMeta: meta,
}
c.TrackCount = len(c.Tracks)
return c
}
// trackCountMatch returns 1.0 when equal, 0.0 when off by >= 50%,
// linear between.
func trackCountMatch(a, b int) float64 {
if a == 0 && b == 0 {
return 1.0
}
if a == 0 || b == 0 {
return 0.0
}
diff := a - b
if diff < 0 {
diff = -diff
}
larger := a
if b > larger {
larger = b
}
frac := float64(diff) / float64(larger)
const halfwayPenalty = 0.5
if frac >= halfwayPenalty {
return 0.0
}
return 1.0 - frac/halfwayPenalty
}
// officialBonus returns 1.0 for Official releases, 0.5 for others
// (Promotion, Bootleg, ...), 0.5 when unknown.
func officialBonus(status string) float64 {
const partial = 0.5
switch strings.ToLower(status) {
case "official":
return 1.0
case "":
return partial
default:
return partial
}
}
// countryBonus gives a mild nudge toward releases from the
// preferred country. Neutral (0.5) when country is absent.
func countryBonus(country string) float64 {
const (
neutral = 0.5
hit = 1.0
)
if country == "" {
return neutral
}
if strings.EqualFold(country, preferredCountry) {
return hit
}
return neutral
}
// parseYear pulls the first 4-digit year out of date strings like
// "2009", "2009-05-18", "".
func parseYear(date string) int {
if len(date) < 4 { //nolint:mnd
return 0
}
y, err := strconv.Atoi(date[:4])
if err != nil {
return 0
}
return y
}
// RankCandidates scores each candidate against the local tracks
// and returns a new slice sorted descending by score. Input slice
// is not modified.
func RankCandidates(local []LocalTrack, candidates []Candidate) []Candidate {
scored := make([]Candidate, 0, len(candidates))
for _, c := range candidates {
scored = append(scored, ScoreCandidate(local, c, len(local)))
}
sort.SliceStable(scored, func(i, j int) bool {
return scored[i].Score > scored[j].Score
})
return scored
}