Files
yellowjacket/backend/autotag/mixedbag.go
T
yonluandClaude Sonnet 5 cbd82a5a74
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s
feat: autotag mixed-bag splitting, search relevance fixes, and multi-library download imports
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
2026-08-10 11:52:26 -04:00

216 lines
6.3 KiB
Go

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 sharing a non-empty (album,
// album-artist) tag pair — a candidate sub-album hiding inside a
// mixed-bag folder.
type TrackCluster struct {
AlbumName string
AlbumArtist string
Tracks []LocalTrack
}
// ClusterByAlbumArtist groups tracks by normalized (album tag,
// album-artist tag) and returns the clusters with at least
// clusterMinSize members, in first-seen order (the caller typically
// passes tracks already ordered by disc/track/path, so this stays
// deterministic run to run). 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 {
type key struct{ album, artist string }
index := make(map[key]int, 4) //nolint:mnd
var clusters []TrackCluster
for _, t := range tracks {
album := Normalize(t.AlbumTag)
if album == "" {
continue
}
k := key{album: album, artist: Normalize(t.AlbumArtistTag)}
if i, ok := index[k]; ok {
clusters[i].Tracks = append(clusters[i].Tracks, t)
continue
}
index[k] = len(clusters)
clusters = append(clusters, TrackCluster{
AlbumName: t.AlbumTag,
AlbumArtist: t.AlbumArtistTag,
Tracks: []LocalTrack{t},
})
}
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
// share an (album, album-artist) pair 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 {
type key struct{ album, artist string }
index := make(map[key]int, 4) //nolint:mnd
var clusters []TrackCluster
// memberOf[i] is 1+the cluster index track i was assigned to (by
// album/artist tag match), or 0 if it never matched anything.
// Tracked by slice position rather than any LocalTrack field —
// AudioFileID/FilePath are frequently zero-valued in this
// package's own tests and would collide, wrongly treating
// distinct untagged tracks as duplicates of one another.
memberOf := make([]int, len(tracks))
for i, t := range tracks {
album := Normalize(t.AlbumTag)
if album == "" {
continue
}
k := key{album: album, artist: Normalize(t.AlbumArtistTag)}
if ci, ok := index[k]; ok {
clusters[ci].Tracks = append(clusters[ci].Tracks, t)
memberOf[i] = ci + 1
continue
}
index[k] = len(clusters)
memberOf[i] = len(clusters) + 1
clusters = append(clusters, TrackCluster{
AlbumName: t.AlbumTag,
AlbumArtist: t.AlbumArtistTag,
Tracks: []LocalTrack{t},
})
}
// 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] - 1; ci >= 0 {
if _, ok := keptIndex[ci]; ok {
continue
}
}
kept = append(kept, TrackCluster{
AlbumName: t.AlbumTag,
AlbumArtist: t.AlbumArtistTag,
Tracks: []LocalTrack{t},
})
}
return kept
}