Autotag: detect "junk drawer" folders with no artist/album consensus and split them into synthetic per-cluster groups instead of forcing one match on an unrelated pile of tracks; repair tagging_items rows left behind by a prior scan orphan-cleanup gap. Explore: fix an exact artist-name search being drowned out by its own catalog entries in intent-prior scoring, and prune stale in_library bookkeeping left behind when a referenced library row is deleted. Download: fix a multi-library regression where every import failed with "no library root configured" — the importer resolved the library root from a legacy single-library config field that nothing populates in the current multi-library model. It now resolves the destination library per-request from the request's own library_id. Also widen the Soulseek search window (12s -> 20s), measured against real request history to be missing available peers on live queries. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
491 lines
14 KiB
Go
491 lines
14 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. Weights sum to
|
|
// 1.0 so a perfect candidate scores 1.0 before the evidence scale.
|
|
const (
|
|
weightTrackAggregate = 0.55
|
|
weightArtist = 0.12 // album-artist vs candidate artist-credit
|
|
weightAlbumTitle = 0.10 // folder album name vs candidate release title
|
|
weightTrackCountMatch = 0.13
|
|
weightReleaseMeta = 0.10 // official + country + RG type, averaged
|
|
|
|
// Country preference: a very mild nudge toward releases from
|
|
// the user's locale. Will become a config option in 012.
|
|
preferredCountry = "US"
|
|
|
|
// Evidence scaling: a folder with very few tracks offers little
|
|
// corroborating signal, so even a perfect title+length match on
|
|
// a single track is inherently less trustworthy than the same
|
|
// match across a full album. The final release score is scaled
|
|
// by evidenceFactor(localTrackCount): folders at or above
|
|
// evidenceFullTracks are unscaled; smaller folders are pulled
|
|
// toward evidenceFloor. This is the "harsher on singletons"
|
|
// lever — a single can never present as a near-certain match on
|
|
// its own, which is also why 012 keeps singletons out of
|
|
// auto-accept entirely.
|
|
evidenceFloor = 0.85
|
|
evidenceFullTracks = 3
|
|
|
|
// Synthetic groups (SplitMixedFolder's tag-clustered sub-albums)
|
|
// are, by construction, a SUBSET of a bigger folder: the folder
|
|
// might not have every track from the release the cluster
|
|
// belongs to. A candidate with more tracks than the synthetic
|
|
// group is therefore expected, not a sign of a wrong match, so
|
|
// its trackCountMatch penalty is softened relative to a real
|
|
// folder (where a track-count gap usually does mean the wrong
|
|
// release). A candidate with FEWER tracks than the group is
|
|
// still scored by the normal (harsher) formula — that's a real
|
|
// mismatch regardless of source.
|
|
syntheticMissingPenaltyScale = 0.35
|
|
syntheticTrackCountFloor = 0.55
|
|
)
|
|
|
|
// vaNames are artist strings that signal "various artists" — used
|
|
// both to detect VA-likely folders and to recognize VA candidate
|
|
// credits. Mirrors beets' VA_ARTISTS.
|
|
var vaNames = map[string]bool{
|
|
"various artists": true,
|
|
"various": true,
|
|
"va": true,
|
|
"v a": true, // "V.A." after Normalize
|
|
"unknown": true,
|
|
}
|
|
|
|
// isVAName reports whether an artist string reads as "various
|
|
// artists". Empty strings are NOT VA — they're unknown, which the
|
|
// artist term already treats as neutral.
|
|
func isVAName(s string) bool {
|
|
return vaNames[Normalize(s)]
|
|
}
|
|
|
|
// vaLikely reports whether a group is probably a various-artists
|
|
// compilation: the album-artist tag says so outright, or the
|
|
// per-track artists have no consensus (≥2 distinct values, or none
|
|
// at all). Mirrors beets' va_likely heuristic.
|
|
func vaLikely(g Group) bool {
|
|
if isVAName(g.AlbumArtist) {
|
|
return true
|
|
}
|
|
|
|
if g.AlbumArtist != "" {
|
|
return false
|
|
}
|
|
|
|
distinct := make(map[string]bool, 2) //nolint:mnd
|
|
|
|
for _, t := range g.Tracks {
|
|
if t.Artist == "" {
|
|
continue
|
|
}
|
|
|
|
distinct[Normalize(t.Artist)] = true
|
|
}
|
|
|
|
return len(distinct) != 1
|
|
}
|
|
|
|
// ScoreCandidate fills in c.Alignments, c.Score, c.Breakdown, and
|
|
// c.TrackCount for a single candidate against the given group.
|
|
// The returned Candidate is safe to copy — no shared state with
|
|
// the caller's slice.
|
|
func ScoreCandidate(g Group, c Candidate) Candidate {
|
|
local := g.Tracks
|
|
|
|
// When the group is one disc of a multi-disc candidate, align
|
|
// and count against that disc only — a "disc 1 of 2" folder is
|
|
// complete for its disc, not half an album.
|
|
targets := alignmentTargets(local, c.Tracks)
|
|
|
|
c.Alignments = AlignTracks(local, targets)
|
|
|
|
var (
|
|
titleSum float64
|
|
lengthSum float64
|
|
counted int
|
|
)
|
|
|
|
for _, a := range c.Alignments {
|
|
if a.Status != AlignmentMatched && a.Status != AlignmentMismatched {
|
|
continue
|
|
}
|
|
|
|
counted++
|
|
|
|
// A recording-MBID lock is identity, not similarity: the
|
|
// title may be garbled in the local tag, but the track IS
|
|
// the candidate's track. Count it as a perfect title so a
|
|
// confirmed match isn't dragged down by its own typos (the
|
|
// UI still shows the textual diff).
|
|
if a.IDMatch {
|
|
titleSum++
|
|
} else {
|
|
titleSum += a.TitleScore
|
|
}
|
|
|
|
lengthSum += a.LengthScore
|
|
}
|
|
|
|
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(targets), len(local), g.Synthetic)
|
|
|
|
// Artist fit: compare the folder's artist against the
|
|
// candidate's release artist-credit. This is a SOFT signal, not
|
|
// a gate — user artist tags are often slightly wrong (misspelled,
|
|
// "&" vs "and", missing "feat."), so an almost-right artist still
|
|
// matches well while a completely different artist is penalised.
|
|
// Critically, artist is otherwise only a *search* filter (see
|
|
// buildMBQueryCascade), and that cascade drops the artist clause
|
|
// on its looser steps — so without this term a same-title,
|
|
// different-artist release scores as if the artist matched.
|
|
artistFit := artistCreditFit(groupArtist(g), c.ArtistCredit)
|
|
|
|
// Album-title fit: the same soft-signal contract for the release
|
|
// title. Without it, a compilation containing the same
|
|
// recordings scores as if it WERE the album ("Greatest Hits" vs
|
|
// the studio album with an identical tracklist).
|
|
albumFit := albumTitleFit(g.AlbumName, c.Title)
|
|
|
|
// Release-meta: official-status + country preference + release-
|
|
// group type, averaged. All mild tie-breakers. (We used to mix
|
|
// in a year bonus too, but that compared candidate years against
|
|
// time.Now() — see git history.)
|
|
const metaTerms = 3.0
|
|
|
|
meta := (officialBonus(c.Status) + countryBonus(c.Country) + rgTypeBonus(c.PrimaryType)) /
|
|
metaTerms
|
|
|
|
// Evidence scaling applies only to MusicBrainz candidates. A
|
|
// local candidate is the *same* release-group already tagged with
|
|
// MBIDs in another library — its confidence comes from that
|
|
// confirmed tagging, not from thin per-track heuristics, so a
|
|
// small-folder local match stays fully trusted (and keeps
|
|
// clearing the localSufficient MB-skip short-circuit). MB
|
|
// matches, by contrast, are fuzzy search results where a
|
|
// single-track folder genuinely offers little corroboration.
|
|
evidence := 1.0
|
|
if c.Source == SourceMusicBrainz {
|
|
evidence = evidenceFactor(len(local))
|
|
}
|
|
|
|
c.Score = (trackAgg*weightTrackAggregate +
|
|
artistFit*weightArtist +
|
|
albumFit*weightAlbumTitle +
|
|
trackCountScore*weightTrackCountMatch +
|
|
meta*weightReleaseMeta) * evidence
|
|
|
|
c.Breakdown = ScoreBreakdown{
|
|
TitleAvg: titleAvg,
|
|
LengthAvg: lengthAvg,
|
|
ArtistFit: artistFit,
|
|
AlbumFit: albumFit,
|
|
TrackCountFit: trackCountScore,
|
|
ReleaseMeta: meta,
|
|
Evidence: evidence,
|
|
}
|
|
c.TrackCount = len(c.Tracks)
|
|
|
|
return c
|
|
}
|
|
|
|
// alignmentTargets returns the candidate tracks the local group
|
|
// should be aligned against. When every local track sits on the
|
|
// same disc D and the candidate spans multiple discs including D,
|
|
// only disc D's tracks are targets — the group key is per-disc, so
|
|
// a single-disc folder must not be penalised for "missing" the
|
|
// candidate's other discs.
|
|
func alignmentTargets(local []LocalTrack, cands []CandidateTrack) []CandidateTrack {
|
|
disc := uniformDisc(local)
|
|
if disc == 0 {
|
|
return cands
|
|
}
|
|
|
|
var (
|
|
onDisc int
|
|
multiDiscs bool
|
|
)
|
|
|
|
for _, c := range cands {
|
|
if c.DiscNumber == disc {
|
|
onDisc++
|
|
} else if c.DiscNumber > 0 {
|
|
multiDiscs = true
|
|
}
|
|
}
|
|
|
|
if !multiDiscs || onDisc == 0 {
|
|
return cands
|
|
}
|
|
|
|
out := make([]CandidateTrack, 0, onDisc)
|
|
|
|
for _, c := range cands {
|
|
if c.DiscNumber == disc {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// uniformDisc returns the disc number shared by every local track,
|
|
// or 0 when discs are mixed or unknown.
|
|
func uniformDisc(local []LocalTrack) int {
|
|
disc := 0
|
|
|
|
for _, t := range local {
|
|
switch {
|
|
case t.DiscNumber <= 0:
|
|
return 0
|
|
case disc == 0:
|
|
disc = t.DiscNumber
|
|
case t.DiscNumber != disc:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
return disc
|
|
}
|
|
|
|
// groupArtist returns the artist string to compare candidates
|
|
// against: the tagging item's album-artist when it's a real name,
|
|
// otherwise the most common per-track artist. Returns "" when
|
|
// nothing is known (neutral, no penalty).
|
|
func groupArtist(g Group) string {
|
|
if g.AlbumArtist != "" && !isVAName(g.AlbumArtist) {
|
|
return g.AlbumArtist
|
|
}
|
|
|
|
return dominantArtist(g.Tracks)
|
|
}
|
|
|
|
// dominantArtist returns the most common non-empty per-track artist
|
|
// in a local group. Ties resolve to the first-seen value so the
|
|
// result is deterministic. Returns "" when no track has an artist,
|
|
// which artistCreditFit treats as "unknown, no penalty".
|
|
func dominantArtist(local []LocalTrack) string {
|
|
counts := make(map[string]int, len(local))
|
|
|
|
var (
|
|
best string
|
|
bestCount int
|
|
)
|
|
|
|
for _, t := range local {
|
|
if t.Artist == "" {
|
|
continue
|
|
}
|
|
|
|
counts[t.Artist]++
|
|
if counts[t.Artist] > bestCount {
|
|
best = t.Artist
|
|
bestCount = counts[t.Artist]
|
|
}
|
|
}
|
|
|
|
return best
|
|
}
|
|
|
|
// artistCreditFit scores how well a folder's artist matches a
|
|
// candidate's release artist-credit, in [0, 1]. Returns 1.0 (no
|
|
// penalty) when either side is unknown or reads as "various
|
|
// artists": absence of artist data must not push a candidate down,
|
|
// and VA credits are placeholders, not disagreements. Reuses the
|
|
// edit-distance similarity so near-right artists stay high.
|
|
func artistCreditFit(localArtist, candidateArtist string) float64 {
|
|
if localArtist == "" || candidateArtist == "" {
|
|
return 1.0
|
|
}
|
|
|
|
if isVAName(localArtist) || isVAName(candidateArtist) {
|
|
return 1.0
|
|
}
|
|
|
|
return titleSimilarity(localArtist, candidateArtist)
|
|
}
|
|
|
|
// albumTitleFit scores how well the folder's album name matches the
|
|
// candidate's release title, in [0, 1]. Neutral (1.0) when either
|
|
// side is unknown — same soft-signal contract as artistCreditFit.
|
|
func albumTitleFit(albumName, candidateTitle string) float64 {
|
|
if albumName == "" || candidateTitle == "" {
|
|
return 1.0
|
|
}
|
|
|
|
return titleSimilarity(albumName, candidateTitle)
|
|
}
|
|
|
|
// evidenceFactor scales the release score down when a folder has too
|
|
// few tracks to corroborate the match. Folders at or above
|
|
// evidenceFullTracks are unscaled (1.0); a single-track folder is
|
|
// pulled to evidenceFloor; two tracks land halfway. See the
|
|
// evidence-scaling note on the weight constants.
|
|
func evidenceFactor(localTrackCount int) float64 {
|
|
if localTrackCount >= evidenceFullTracks {
|
|
return 1.0
|
|
}
|
|
|
|
if localTrackCount <= 1 {
|
|
return evidenceFloor
|
|
}
|
|
|
|
span := float64(localTrackCount-1) / float64(evidenceFullTracks-1)
|
|
|
|
return evidenceFloor + (1.0-evidenceFloor)*span
|
|
}
|
|
|
|
// trackCountMatch returns 1.0 when equal, 0.0 when off by >= 50%,
|
|
// linear between. When synthetic is true and the candidate (a) has
|
|
// MORE tracks than the local group (b) — the group having fewer
|
|
// tracks than the full release, exactly what's expected from a
|
|
// tag-clustered subset of a folder — the penalty is softened instead
|
|
// of using the normal harsh formula. Fewer candidate tracks than
|
|
// local (b > a) always uses the normal formula: that pattern means
|
|
// the group has tracks the candidate release doesn't, which is a
|
|
// real mismatch however the group was built.
|
|
func trackCountMatch(a, b int, synthetic bool) float64 {
|
|
if a == 0 && b == 0 {
|
|
return 1.0
|
|
}
|
|
|
|
if a == 0 || b == 0 {
|
|
return 0.0
|
|
}
|
|
|
|
if synthetic && a > b {
|
|
diff := a - b
|
|
frac := float64(diff) / float64(a)
|
|
|
|
return max(1.0-frac*syntheticMissingPenaltyScale, syntheticTrackCountFloor)
|
|
}
|
|
|
|
diff := a - b
|
|
if diff < 0 {
|
|
diff = -diff
|
|
}
|
|
|
|
larger := max(a, 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
|
|
|
|
if strings.EqualFold(status, "official") {
|
|
return 1.0
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// rgTypeBonus nudges toward studio albums over compilations and
|
|
// live releases when the track evidence is otherwise comparable —
|
|
// Picard weights release type heavily for the same reason. The
|
|
// nudge is mild: a genuine single folder still matches its Single
|
|
// release because track count and alignment dominate. Unknown
|
|
// types (including all local candidates) sit near the top so the
|
|
// term only separates candidates we positively know differ.
|
|
func rgTypeBonus(primaryType string) float64 {
|
|
switch strings.ToLower(primaryType) {
|
|
case "album":
|
|
return 1.0
|
|
case "ep":
|
|
return 0.9
|
|
case "single":
|
|
return 0.85
|
|
case "":
|
|
return 0.85
|
|
case "soundtrack":
|
|
return 0.7
|
|
case "compilation", "live":
|
|
return 0.6
|
|
default:
|
|
return 0.7
|
|
}
|
|
}
|
|
|
|
// 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 group and
|
|
// returns a new slice sorted descending by score. Input slice is
|
|
// not modified.
|
|
func RankCandidates(g Group, candidates []Candidate) []Candidate {
|
|
scored := make([]Candidate, 0, len(candidates))
|
|
for _, c := range candidates {
|
|
scored = append(scored, ScoreCandidate(g, c))
|
|
}
|
|
|
|
sort.SliceStable(scored, func(i, j int) bool {
|
|
return scored[i].Score > scored[j].Score
|
|
})
|
|
|
|
return scored
|
|
}
|