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 }