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
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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,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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user