feat: autotag mixed-bag splitting, search relevance fixes, and multi-library download imports
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s

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
This commit is contained in:
2026-08-10 11:52:26 -04:00
co-authored by Claude Sonnet 5
parent e190fd75b9
commit cbd82a5a74
70 changed files with 3617 additions and 129 deletions
+62 -7
View File
@@ -19,12 +19,14 @@ import (
//
// libraryID || 0 || normalized_parent_dir || 0 || disc_number
//
// where the parent directory is lower-cased. The folder is taken
// as the album boundary — including the album tag string would
// fragment albums whose tracks carry slightly different tags
// (`Abbey Road` vs `Abbey Road (Remastered 2009)`, etc.). The
// album name is still surfaced in `tagging_items.album_name` for
// the review UI; it just doesn't decide grouping.
// where the parent directory is lower-cased and disc_number is
// normalized so an untagged disc (0) folds into disc 1 — see
// normalizeDiscNumber. The folder is taken as the album boundary —
// including the album tag string would fragment albums whose tracks
// carry slightly different tags (`Abbey Road` vs `Abbey Road
// (Remastered 2009)`, etc.). The album name is still surfaced in
// `tagging_items.album_name` for the review UI; it just doesn't
// decide grouping.
//
// Using SHA-1 matches the codebase's existing non-crypto
// deterministic-key convention; collision risk at album-group
@@ -41,7 +43,60 @@ func GroupKey(
h.Write([]byte{0})
h.Write([]byte(parentDir))
h.Write([]byte{0})
h.Write([]byte(strconv.Itoa(discNumber)))
h.Write([]byte(strconv.Itoa(normalizeDiscNumber(discNumber))))
return hex.EncodeToString(h.Sum(nil))
}
// SyntheticGroupKey returns a deterministic identifier for a
// tag-clustered sub-group carved out of parentGroupKey by
// SplitMixedFolder — same SHA-1-over-null-separated-fields shape as
// GroupKey, but keyed on the cluster's (album, album-artist) tags
// instead of a directory, since a synthetic group's tracks don't
// share a directory boundary distinct from their siblings left
// behind in the parent folder.
func SyntheticGroupKey(parentGroupKey, albumName, albumArtist string) string {
h := sha1.New() //nolint:gosec // see package doc — grouping only.
h.Write([]byte(parentGroupKey))
h.Write([]byte{0})
h.Write([]byte(Normalize(albumName)))
h.Write([]byte{0})
h.Write([]byte(Normalize(albumArtist)))
return hex.EncodeToString(h.Sum(nil))
}
// SyntheticTrackGroupKey returns a deterministic identifier for a
// single leftover track carved out of a mixed-bag folder by
// SplitMixedFolder's singleton fallback (autotag.SplitPlan). Keyed on
// the track's own audio_files id rather than its tags — two
// untagged leftover tracks would otherwise both normalize to the
// same empty (album, album-artist) pair and collide under
// SyntheticGroupKey.
func SyntheticTrackGroupKey(parentGroupKey string, audioFileID int64) string {
h := sha1.New() //nolint:gosec // see package doc — grouping only.
h.Write([]byte(parentGroupKey))
h.Write([]byte{0})
h.Write([]byte("track"))
h.Write([]byte{0})
h.Write([]byte(strconv.FormatInt(audioFileID, 10)))
return hex.EncodeToString(h.Sum(nil))
}
// normalizeDiscNumber folds a missing/invalid disc tag (<= 0) into
// disc 1 for grouping purposes. Without this, a folder where only
// some tracks carry an explicit "disc 1 of 1" tag — common when
// files were ripped or re-tagged at different times — splits into
// two tagging groups for what is really one single-disc album: the
// untagged tracks hash to disc 0, the tagged ones to disc 1. A
// genuine multi-disc release still separates correctly, since its
// disc-2-and-up tracks carry an explicit non-zero, non-one disc
// number.
func normalizeDiscNumber(discNumber int) int {
if discNumber <= 0 {
return 1
}
return discNumber
}
+23
View File
@@ -88,6 +88,29 @@ func TestGroupKey_DistinctInputsDiffer(t *testing.T) {
}
}
func TestGroupKey_UntaggedDiscFoldsIntoDiscOne(t *testing.T) {
t.Parallel()
// A folder where only some tracks carry an explicit disc tag must
// not split: the untagged tracks (disc 0, dhowden/tag's zero value
// for a missing frame) should group with the ones tagged disc 1.
untagged := autotag.GroupKey(1, "/music/Artist/Album/01.mp3", 0)
tagged := autotag.GroupKey(1, "/music/Artist/Album/02.mp3", 1)
if untagged != tagged {
t.Fatalf(
"disc 0 and disc 1 in the same folder should share a key, got %q vs %q",
untagged, tagged,
)
}
// A genuine disc 2 must still separate from disc 1/untagged.
discTwo := autotag.GroupKey(1, "/music/Artist/Album/01.mp3", 2)
if discTwo == tagged {
t.Fatalf("disc 2 should not share a key with disc 1, got %q", discTwo)
}
}
func TestGroupKey_AmbiguityBoundary(t *testing.T) {
t.Parallel()
+10 -8
View File
@@ -39,14 +39,16 @@ func (r *LocalResolver) LocalTracksForGroup(
out := make([]LocalTrack, 0, len(rows))
for _, row := range rows {
out = append(out, LocalTrack{
AudioFileID: row.ID,
FilePath: row.FilePath,
Title: row.Title,
Artist: row.ArtistName,
TrackNumber: int(row.TrackNumber),
DiscNumber: int(row.DiscNumber),
LengthMillis: row.LengthMilliseconds,
RecordingMBID: row.RecordingMbid,
AudioFileID: row.ID,
FilePath: row.FilePath,
Title: row.Title,
Artist: row.ArtistName,
TrackNumber: int(row.TrackNumber),
DiscNumber: int(row.DiscNumber),
LengthMillis: row.LengthMilliseconds,
RecordingMBID: row.RecordingMbid,
AlbumTag: row.AlbumName,
AlbumArtistTag: row.AlbumArtist,
})
}
+41 -1
View File
@@ -61,6 +61,18 @@ type MBClient interface {
query string,
limit int,
) ([]MBReleaseGroupHit, int, error)
// SearchReleaseGroupsLocal searches the offline dump-derived
// catalog for release groups matching albumName — no network
// round-trip. ok is false when the local catalog isn't
// populated yet (or the implementation has no offline index),
// telling the caller to rely on the network cascade alone; ok
// true with zero hits means the catalog was consulted and
// genuinely has nothing.
SearchReleaseGroupsLocal(
ctx context.Context,
albumName string,
limit int,
) (hits []MBReleaseGroupHit, ok bool)
SearchRecordings(
ctx context.Context,
query string,
@@ -128,12 +140,40 @@ func (r *MBResolver) ResolveMB(ctx context.Context, g Group) ([]Candidate, error
}
nArtist := Normalize(groupArtist(g))
steps := buildMBQueryCascade(nAlbum, nArtist, len(g.Tracks), vaLikely(g))
seen := make(map[string]bool)
var merged []Candidate
// Local-index pass: the offline dump-derived catalog covers
// essentially every popular release group, so try it before
// spending any rate-limited search calls. This never skips
// BrowseReleases (the catalog doesn't carry per-release
// tracklists) but it very often means the network Lucene
// cascade below never has to run at all.
if localHits, ok := r.client.SearchReleaseGroupsLocal(ctx, g.AlbumName, r.limit); ok {
added := r.fanOutBrowse(ctx, g, localHits, "index", seen, &merged)
r.logger.Debug(
"local index search done",
"hits", len(localHits), "new_candidates", added,
)
if added > 0 {
ranked := RankCandidates(g, merged)
if len(ranked) > 0 && ranked[0].Score >= cascadeSufficient {
r.logger.Info(
"MB cascade stopped — sufficient local-index candidate",
"score", ranked[0].Score,
)
return merged, nil
}
}
}
steps := buildMBQueryCascade(nAlbum, nArtist, len(g.Tracks), vaLikely(g))
for _, step := range steps {
hits, _, err := r.client.SearchReleaseGroups(ctx, step.query, r.limit)
if err != nil {
+94
View File
@@ -23,6 +23,8 @@ type fakeMBClient struct {
lookupRGs map[string]MBReleaseGroupHit
searchRecs []MBRecordingHit
recRelsByMBID map[string][]MBReleaseRef
localHits []MBReleaseGroupHit
localOK bool
}
func (f *fakeMBClient) SearchReleaseGroups(
@@ -36,6 +38,15 @@ func (f *fakeMBClient) SearchReleaseGroups(
return hits, len(hits), nil
}
// SearchReleaseGroupsLocal is a no-op by default (ok=false), so
// existing cascade tests exercise the network path unchanged. Set
// localHits / localOK on the fake to exercise the index-first path.
func (f *fakeMBClient) SearchReleaseGroupsLocal(
_ context.Context, _ string, _ int,
) ([]MBReleaseGroupHit, bool) {
return f.localHits, f.localOK
}
func (f *fakeMBClient) BrowseReleases(
_ context.Context, mbid string,
) ([]MBRelease, error) {
@@ -188,6 +199,89 @@ func TestMBResolver_CascadeStopsWhenSufficient(t *testing.T) {
}
}
func TestMBResolver_LocalIndexSufficientSkipsNetworkSearch(t *testing.T) {
t.Parallel()
fake := &fakeMBClient{
localOK: true,
localHits: []MBReleaseGroupHit{
{MBID: "rg1", Title: "Abbey Road"},
},
browseByMBID: map[string][]MBRelease{
"rg1": {{
MBID: "rel1", Title: "Abbey Road", Status: "Official",
Tracks: []CandidateTrack{
{Position: 1, Title: "Come Together", LengthMillis: 259000},
},
}},
},
}
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
cands, err := r.ResolveMB(context.Background(), abbeyRoadGroup())
if err != nil {
t.Fatalf("ResolveMB: %v", err)
}
if len(cands) != 1 {
t.Fatalf("expected 1 candidate, got %d", len(cands))
}
if cands[0].Provenance != "index" {
t.Errorf("provenance = %q, want 'index'", cands[0].Provenance)
}
if len(fake.queries) != 0 {
t.Errorf(
"expected zero network search queries when the local index sufficed, got %d: %v",
len(fake.queries), fake.queries,
)
}
}
func TestMBResolver_LocalIndexThinFallsThroughToNetwork(t *testing.T) {
t.Parallel()
// Local index is "ready" but has nothing plausible for this
// album — the cascade must still fall through to the network
// steps exactly as if there were no local index at all.
fake := &fakeMBClient{
localOK: true,
localHits: nil,
searchByStep: map[int][]MBReleaseGroupHit{
1: {{MBID: "rg1", Title: "Abbey Road"}},
},
browseByMBID: map[string][]MBRelease{
"rg1": {{
MBID: "rel1", Title: "Abbey Road", Status: "Official",
Tracks: []CandidateTrack{
{Position: 1, Title: "Come Together", LengthMillis: 259000},
},
}},
},
}
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
cands, err := r.ResolveMB(context.Background(), abbeyRoadGroup())
if err != nil {
t.Fatalf("ResolveMB: %v", err)
}
if len(cands) != 1 {
t.Fatalf("expected 1 candidate, got %d", len(cands))
}
if cands[0].Provenance != "no-track-count" {
t.Errorf("provenance = %q, want 'no-track-count'", cands[0].Provenance)
}
if len(fake.queries) != 2 { //nolint:mnd
t.Errorf("expected the usual 2 network queries, got %d", len(fake.queries))
}
}
func TestMBResolver_CascadeContinuesPastMediocreHits(t *testing.T) {
t.Parallel()
+215
View File
@@ -0,0 +1,215 @@
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
}
+246
View File
@@ -0,0 +1,246 @@
package autotag
import "testing"
func junkDrawerTracks() []LocalTrack {
return []LocalTrack{
{
Title: "Song A", Artist: "Artist One",
AlbumTag: "Album One", AlbumArtistTag: "Artist One",
},
{
Title: "Song B", Artist: "Artist One",
AlbumTag: "Album One", AlbumArtistTag: "Artist One",
},
{
Title: "Song C", Artist: "Artist Two",
AlbumTag: "Album Two", AlbumArtistTag: "Artist Two",
},
{
Title: "Song D", Artist: "Artist Two",
AlbumTag: "Album Two", AlbumArtistTag: "Artist Two",
},
{
Title: "Song E", Artist: "Artist Three",
AlbumTag: "Album Three", AlbumArtistTag: "Artist Three",
},
}
}
func TestIsMixedBag_DetectsJunkDrawer(t *testing.T) {
t.Parallel()
g := Group{Tracks: junkDrawerTracks()}
if !IsMixedBag(g) {
t.Fatal("expected a folder with no artist or album consensus to be flagged mixed-bag")
}
}
func TestIsMixedBag_RealAlbumNotFlagged(t *testing.T) {
t.Parallel()
g := Group{
AlbumArtist: "The Beatles",
Tracks: []LocalTrack{
{Title: "Come Together", Artist: "The Beatles"},
{Title: "Something", Artist: "The Beatles"},
{Title: "Maxwell's Silver Hammer", Artist: "The Beatles"},
{Title: "Oh! Darling", Artist: "The Beatles"},
},
}
if IsMixedBag(g) {
t.Fatal("a coherent single-artist album must not be flagged mixed-bag")
}
}
func TestIsMixedBag_ExplicitAlbumArtistOverridesHeuristic(t *testing.T) {
t.Parallel()
// Per-track artists disagree (feat. credits, remixers, etc.) but
// the folder carries a real album-artist tag — trust it.
g := Group{
AlbumArtist: "Some Artist",
Tracks: []LocalTrack{
{Title: "Track 1", Artist: "Some Artist"},
{Title: "Track 2", Artist: "Some Artist feat. Guest"},
{Title: "Track 3", Artist: "Someone Else"},
{Title: "Track 4", Artist: "Some Artist"},
},
}
if IsMixedBag(g) {
t.Fatal("explicit non-VA album-artist tag should override the divergence heuristic")
}
}
func TestIsMixedBag_VACompilationNotFlagged(t *testing.T) {
t.Parallel()
// Various-artists compilation: artists diverge but every track
// agrees on the album — this is vaLikely's case, not a junk
// drawer, so IsMixedBag must require album divergence too.
g := Group{
Tracks: []LocalTrack{
{Title: "Track 1", Artist: "Artist One", AlbumTag: "Now That's What I Call Music"},
{Title: "Track 2", Artist: "Artist Two", AlbumTag: "Now That's What I Call Music"},
{Title: "Track 3", Artist: "Artist Three", AlbumTag: "Now That's What I Call Music"},
{Title: "Track 4", Artist: "Artist Four", AlbumTag: "Now That's What I Call Music"},
},
}
if IsMixedBag(g) {
t.Fatal("a VA compilation with consistent album tags must not be flagged mixed-bag")
}
}
func TestIsMixedBag_TooFewTracksNotFlagged(t *testing.T) {
t.Parallel()
g := Group{
Tracks: []LocalTrack{
{Title: "Track 1", Artist: "Artist One", AlbumTag: "Album One"},
{Title: "Track 2", Artist: "Artist Two", AlbumTag: "Album Two"},
},
}
if IsMixedBag(g) {
t.Fatal("a folder below mixedBagMinTracks must not be flagged, even if it diverges")
}
}
func TestClusterByAlbumArtist_FindsSubAlbums(t *testing.T) {
t.Parallel()
tracks := junkDrawerTracks() // two 2-track clusters + one true singleton
clusters := ClusterByAlbumArtist(tracks)
if len(clusters) != 2 { //nolint:mnd
t.Fatalf("expected 2 clusters (Album One, Album Two), got %d: %+v", len(clusters), clusters)
}
for _, c := range clusters {
if len(c.Tracks) != 2 { //nolint:mnd
t.Errorf("cluster %q: expected 2 tracks, got %d", c.AlbumName, len(c.Tracks))
}
}
total := 0
for _, c := range clusters {
total += len(c.Tracks)
}
if total != 4 { //nolint:mnd
t.Errorf(
"expected 4 clustered tracks total (Song E stays unclustered), got %d",
total,
)
}
}
func TestClusterByAlbumArtist_NoAlbumTagStaysUnclustered(t *testing.T) {
t.Parallel()
tracks := []LocalTrack{
{Title: "Track 1", Artist: "Artist One"},
{Title: "Track 2", Artist: "Artist One"},
}
if clusters := ClusterByAlbumArtist(tracks); len(clusters) != 0 {
t.Fatalf("tracks with no album tag must never cluster, got %+v", clusters)
}
}
func TestClusterByAlbumArtist_DeterministicOrder(t *testing.T) {
t.Parallel()
tracks := junkDrawerTracks()
first := ClusterByAlbumArtist(tracks)
second := ClusterByAlbumArtist(tracks)
if len(first) != len(second) {
t.Fatalf("non-deterministic cluster count: %d vs %d", len(first), len(second))
}
for i := range first {
if first[i].AlbumName != second[i].AlbumName {
t.Errorf(
"non-deterministic cluster order at %d: %q vs %q",
i,
first[i].AlbumName,
second[i].AlbumName,
)
}
}
if first[0].AlbumName != "Album One" {
t.Errorf("expected first-seen cluster order, got %q first", first[0].AlbumName)
}
}
func TestSplitPlan_ClustersPlusSingletonForEveryLeftover(t *testing.T) {
t.Parallel()
tracks := junkDrawerTracks() // two 2-track clusters + one true singleton (Song E)
plan := SplitPlan(tracks)
total := 0
for _, c := range plan {
total += len(c.Tracks)
}
if total != len(tracks) {
t.Fatalf("expected every track accounted for, got %d of %d", total, len(tracks))
}
var singletons, clustered int
for _, c := range plan {
switch len(c.Tracks) {
case 1:
singletons++
case 2: //nolint:mnd
clustered++
default:
t.Errorf("unexpected cluster size %d: %+v", len(c.Tracks), c)
}
}
if singletons != 1 {
t.Errorf("expected exactly 1 singleton (Song E), got %d", singletons)
}
if clustered != 2 { //nolint:mnd
t.Errorf("expected exactly 2 clustered groups, got %d", clustered)
}
}
func TestSplitPlan_AllUnrelatedTracksAllBecomeSingletons(t *testing.T) {
t.Parallel()
tracks := []LocalTrack{
{Title: "Track 1", Artist: "Artist One", AlbumTag: "Album A"},
{Title: "Track 2", Artist: "Artist Two", AlbumTag: "Album B"},
{Title: "Track 3", Artist: "Artist Three"}, // no album tag at all
}
plan := SplitPlan(tracks)
if len(plan) != len(tracks) {
t.Fatalf(
"expected every unrelated track to become its own singleton, got %d clusters for %d tracks",
len(plan),
len(tracks),
)
}
for _, c := range plan {
if len(c.Tracks) != 1 {
t.Errorf("expected singleton cluster, got %d tracks: %+v", len(c.Tracks), c)
}
}
}
+30 -3
View File
@@ -33,6 +33,19 @@ const (
// 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
@@ -139,7 +152,7 @@ func ScoreCandidate(g Group, c Candidate) Candidate {
trackAgg := ((titleAvg*weightTitle + lengthAvg*weightLength) / trackWeightSum) * coverage
trackCountScore := trackCountMatch(len(targets), len(local))
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
@@ -347,8 +360,15 @@ func evidenceFactor(localTrackCount int) float64 {
}
// trackCountMatch returns 1.0 when equal, 0.0 when off by >= 50%,
// linear between.
func trackCountMatch(a, b int) float64 {
// 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
}
@@ -357,6 +377,13 @@ func trackCountMatch(a, b int) float64 {
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
+49
View File
@@ -49,6 +49,55 @@ func TestRankCandidates_PrefersExactTrackCountMatch(t *testing.T) {
}
}
func TestScoreCandidate_SyntheticGroupSoftensMissingTrackPenalty(t *testing.T) {
t.Parallel()
// Two tracks pulled from a mixed-bag folder, tag-clustered as a
// subset of a 5-track release — exactly what SplitMixedFolder
// produces. A candidate release with the other 3 tracks the
// folder simply never had must not be penalized nearly as hard
// as a real folder missing 3 of 5 tracks would be.
local := []autotag.LocalTrack{
{Title: "A", TrackNumber: 1, LengthMillis: 200000},
{Title: "B", TrackNumber: 2, LengthMillis: 200000},
}
candidate := autotag.Candidate{
ReleaseMBID: "full-release",
Title: "Album",
Status: "Official",
Tracks: []autotag.CandidateTrack{
{Position: 1, Title: "A", LengthMillis: 200000},
{Position: 2, Title: "B", LengthMillis: 200000},
{Position: 3, Title: "C", LengthMillis: 200000},
{Position: 4, Title: "D", LengthMillis: 200000},
{Position: 5, Title: "E", LengthMillis: 200000},
},
}
fromRealFolder := autotag.ScoreCandidate(
autotag.Group{Tracks: local, Synthetic: false}, candidate,
)
fromSynthetic := autotag.ScoreCandidate(
autotag.Group{Tracks: local, Synthetic: true}, candidate,
)
if fromSynthetic.Breakdown.TrackCountFit <= fromRealFolder.Breakdown.TrackCountFit {
t.Errorf(
"synthetic track-count fit (%.3f) should exceed the real-folder fit (%.3f) for the same gap",
fromSynthetic.Breakdown.TrackCountFit,
fromRealFolder.Breakdown.TrackCountFit,
)
}
if fromSynthetic.Score <= fromRealFolder.Score {
t.Errorf(
"synthetic group score (%.3f) should exceed the real-folder score (%.3f)",
fromSynthetic.Score, fromRealFolder.Score,
)
}
}
func TestRankCandidates_PrefersOfficial(t *testing.T) {
t.Parallel()
+9 -2
View File
@@ -59,9 +59,16 @@ func Recommend(g Group, candidates []Candidate) Recommendation {
// Cap: missing or unmatched tracks mean the alignment itself is
// incomplete, however good the matched tracks look (beets caps
// these penalties at "medium" the same way).
// these penalties at "medium" the same way). A synthetic
// (tag-clustered) group is, by construction, a subset of a
// bigger folder, so AlignmentMissing (the candidate has tracks
// the group doesn't) is the expected shape rather than a defect
// and doesn't cap the recommendation. AlignmentUnmatched (the
// group has a track the candidate doesn't) is still a real
// discrepancy regardless of source.
for _, a := range top.Alignments {
if a.Status == AlignmentMissing || a.Status == AlignmentUnmatched {
if a.Status == AlignmentUnmatched ||
(a.Status == AlignmentMissing && !g.Synthetic) {
rec = minRecommendation(rec, RecommendationMedium)
break
+40
View File
@@ -97,6 +97,46 @@ func TestRecommend_AlignmentDefectsCapAtMedium(t *testing.T) {
}
}
func TestRecommend_SyntheticGroupMissingTracksDoNotCap(t *testing.T) {
t.Parallel()
top := mkScoredCandidate("rg1", 0.95)
top.Alignments = []TrackAlignment{
{Status: AlignmentMatched},
{Status: AlignmentMissing, LocalIndex: -1},
}
g := fullGroup()
g.Synthetic = true
if got := Recommend(g, []Candidate{top}); got != RecommendationStrong {
t.Errorf(
"synthetic group with only missing (not unmatched) tracks: Recommend = %q, want strong",
got,
)
}
}
func TestRecommend_SyntheticGroupUnmatchedTracksStillCap(t *testing.T) {
t.Parallel()
top := mkScoredCandidate("rg1", 0.95)
top.Alignments = []TrackAlignment{
{Status: AlignmentMatched},
{Status: AlignmentUnmatched, LocalIndex: 1},
}
g := fullGroup()
g.Synthetic = true
if got := Recommend(g, []Candidate{top}); got != RecommendationMedium {
t.Errorf(
"synthetic group with an unmatched local track: Recommend = %q, want medium",
got,
)
}
}
func TestRecommend_ThinEvidenceCapsAtMedium(t *testing.T) {
t.Parallel()
+2
View File
@@ -104,6 +104,7 @@ func (s *Scorer) scoreGroup(
AlbumName: item.AlbumName,
AlbumArtist: item.AlbumArtist,
Tracks: locals,
Synthetic: item.Synthetic != 0,
}
localHits, err := s.local.ResolveLocal(ctx, item.AlbumName)
@@ -142,6 +143,7 @@ func (s *Scorer) scoreGroup(
LocalTracks: locals,
Candidates: candidates,
Recommendation: Recommend(g, candidates),
Synthetic: g.Synthetic,
}, nil
}
+14
View File
@@ -338,6 +338,12 @@ func (c *idFakeClient) LookupReleaseGroup(
return autotag.MBReleaseGroupHit{}, nil
}
func (c *idFakeClient) SearchReleaseGroupsLocal(
_ context.Context, _ string, _ int,
) ([]autotag.MBReleaseGroupHit, bool) {
return nil, false
}
func TestScorer_PersistScoreWritesTopMatch(t *testing.T) {
t.Parallel()
@@ -462,3 +468,11 @@ func (c *countingMBClient) LookupReleaseGroup(
return autotag.MBReleaseGroupHit{}, nil
}
// SearchReleaseGroupsLocal is not a network call — it never counts
// against the zero-network-call assertions this fake exists for.
func (c *countingMBClient) SearchReleaseGroupsLocal(
_ context.Context, _ string, _ int,
) ([]autotag.MBReleaseGroupHit, bool) {
return nil, false
}
+18
View File
@@ -12,6 +12,15 @@ type LocalTrack struct {
DiscNumber int
LengthMillis int64
RecordingMBID string
// AlbumTag/AlbumArtistTag are this track's OWN album tags (via
// its release_group link), independent of the folder-level
// Group.AlbumName/AlbumArtist below. A coherent album's tracks
// all carry the same values here; a junk-drawer folder's don't.
// Used only by SplitMixedFolder's clustering — the scorer itself
// still ranks against Group.AlbumName/AlbumArtist.
AlbumTag string
AlbumArtistTag string
}
// Group is the folder-level context candidates are ranked against:
@@ -21,6 +30,14 @@ type Group struct {
AlbumName string
AlbumArtist string
Tracks []LocalTrack
// Synthetic marks a group carved out of a mixed-bag folder by
// SplitMixedFolder rather than corresponding to a real directory.
// Its tracks are a tag-matched subset of a bigger folder, so a
// candidate with MORE tracks than the group is expected, not a
// sign of a bad match — see the synthetic-aware evidence/track-
// count handling in rank.go and recommend.go.
Synthetic bool
}
// CandidateSource distinguishes candidates served from the local
@@ -125,4 +142,5 @@ type GroupScore struct {
LocalTracks []LocalTrack
Candidates []Candidate // sorted by Score, descending
Recommendation Recommendation
Synthetic bool // true for a SplitMixedFolder-derived group
}