package autotag // mixedBagMinTracks is the smallest folder IsMixedBag will flag. // Below this, artist/album divergence is just as likely to be // sampling noise (a 2-track folder with two different artists could // easily be a legitimate 2-track EP with a featured artist) as it is // a genuine junk-drawer folder. const mixedBagMinTracks = 4 // clusterMinSize is the smallest tag-matched group ClusterByAlbumArtist // will surface as a splittable cluster. A single track sharing no // album/artist with anything else in the folder gains nothing from // becoming its own one-track group — it stays in the leftover folder, // which the existing evidence-scaling (rank.go) already treats // appropriately harshly for a 1-track match. const clusterMinSize = 2 // IsMixedBag reports whether a group's local tracks look like an // unrelated pile of songs rather than one release: no artist // consensus AND no album consensus, across enough tracks that the // divergence isn't just noise. An explicit, non-VA album-artist tag // on the folder overrides the heuristic — a user (or a prior tagger) // who set a real album-artist meant this to read as one release. func IsMixedBag(g Group) bool { if len(g.Tracks) < mixedBagMinTracks { return false } if g.AlbumArtist != "" && !isVAName(g.AlbumArtist) { return false } return !hasTagConsensus(trackArtistTags(g.Tracks)) && !hasTagConsensus(trackAlbumTags(g.Tracks)) } // hasTagConsensus reports whether every non-empty value in vals // normalizes to the same string. Empty values are ignored — missing // tags are unknown, not disagreement. A folder with zero non-empty // values has no consensus either way; callers only reach here after // already requiring enough tracks to matter. func hasTagConsensus(vals []string) bool { distinct := make(map[string]bool, 2) //nolint:mnd for _, v := range vals { if v == "" { continue } distinct[Normalize(v)] = true if len(distinct) > 1 { return false } } return len(distinct) == 1 } func trackArtistTags(tracks []LocalTrack) []string { out := make([]string, len(tracks)) for i, t := range tracks { out[i] = t.Artist } return out } func trackAlbumTags(tracks []LocalTrack) []string { out := make([]string, len(tracks)) for i, t := range tracks { out[i] = t.AlbumTag } return out } // TrackCluster is a set of local tracks whose album (and album-artist) // tags are close enough to describe the same release — a candidate // sub-album hiding inside a mixed-bag folder. type TrackCluster struct { AlbumName string AlbumArtist string Tracks []LocalTrack } // clusterFuzzyThreshold is the maximum stringDist between a track's // album tag (and, separately, its album-artist tag) and the tags that // started a cluster for the two to be considered the same album. // Tight enough to keep genuinely different albums by the same artist // apart, loose enough to absorb the kind of typo, dropped diacritic, // or stray whitespace that exact Normalize()-equality clustering used // to split into separate clusters — the same distance function // candidate scoring already uses to decide two titles describe the // same release (rank.go's albumTitleFit/artistCreditFit), applied to // the same question here: do these two tags name the same thing. const clusterFuzzyThreshold = 0.15 // clusterTracks groups tracks into candidate sub-albums: a track // joins the first existing cluster whose founding track's album tag // is within clusterFuzzyThreshold (in stringDist terms), and whose // album-artist tag either also matches or is empty on either side — // same "empty means unknown, not a mismatch" contract as // artistCreditFit — or else it starts a new cluster. Tracks with no // album tag are left unassigned (memberOf entry -1). // // Comparing only against the cluster's founding track, not a running // centroid or every member, keeps this O(tracks × clusters) and // deterministic in first-seen order — the order ClusterByAlbumArtist // and SplitPlan's callers already depend on (they pass tracks ordered // by disc/track/path). func clusterTracks(tracks []LocalTrack) (clusters []TrackCluster, memberOf []int) { type rep struct{ album, artist string } var reps []rep memberOf = make([]int, len(tracks)) for i, t := range tracks { if Normalize(t.AlbumTag) == "" { memberOf[i] = -1 continue } joined := -1 for ci, r := range reps { artistMatches := t.AlbumArtistTag == "" || r.artist == "" || stringDist(t.AlbumArtistTag, r.artist) <= clusterFuzzyThreshold if artistMatches && stringDist(t.AlbumTag, r.album) <= clusterFuzzyThreshold { joined = ci break } } if joined < 0 { joined = len(clusters) reps = append(reps, rep{album: t.AlbumTag, artist: t.AlbumArtistTag}) clusters = append(clusters, TrackCluster{ AlbumName: t.AlbumTag, AlbumArtist: t.AlbumArtistTag, }) } clusters[joined].Tracks = append(clusters[joined].Tracks, t) memberOf[i] = joined } return clusters, memberOf } // ClusterByAlbumArtist groups tracks by album/album-artist tag // similarity (see clusterTracks) and returns the clusters with at // least clusterMinSize members, in first-seen order. Tracks with no // album tag, or whose cluster never reaches clusterMinSize, are // omitted — they belong in the leftover folder, not a synthetic group // of their own. func ClusterByAlbumArtist(tracks []LocalTrack) []TrackCluster { clusters, _ := clusterTracks(tracks) out := clusters[:0] for _, c := range clusters { if len(c.Tracks) >= clusterMinSize { out = append(out, c) } } return out } // SplitPlan returns the full set of synthetic groups a mixed-bag // folder should be torn into: ClusterByAlbumArtist's tag-matched // sub-albums, plus a one-track cluster for every track that didn't // end up sharing a cluster with anything else in the folder. Unlike // ClusterByAlbumArtist alone — which leaves unclustered tracks behind // in the parent group, where they'd still get folded into whatever // partial-album match the scorer finds for the rest of the pile — // this guarantees every track leaves the parent, so a folder of // entirely unrelated singles (no two tracks share an album tag) still // gets torn apart instead of being scored as one bogus album with a // pile of "extra" tracks. Each singleton's evidence-scaled score // (rank.go) keeps it appropriately humble on its own — it just no // longer drags an unrelated release's score down, or gets dragged // down by one. func SplitPlan(tracks []LocalTrack) []TrackCluster { clusters, memberOf := clusterTracks(tracks) // Clusters that never reached clusterMinSize don't survive as a // group; their sole member falls through to the singleton pass // below instead. kept := make([]TrackCluster, 0, len(clusters)) keptIndex := make(map[int]int, len(clusters)) for oldIdx, c := range clusters { if len(c.Tracks) >= clusterMinSize { keptIndex[oldIdx] = len(kept) kept = append(kept, c) } } for i, t := range tracks { if ci := memberOf[i]; ci >= 0 { if _, ok := keptIndex[ci]; ok { continue } } kept = append(kept, TrackCluster{ AlbumName: t.AlbumTag, AlbumArtist: t.AlbumArtistTag, Tracks: []LocalTrack{t}, }) } return kept }