feat(albums): get an album's track total from the files, not the catalog

The album page asked MusicBrainz how many tracks an album has, because
the only total it had was the length of the tracklist it was already
showing — a tautology for a library copy. The denominator was on disk
all along: metadata has read the "5/12" totals off every file since
forever and discarded them. They persist to
release_group_recordings.total_tracks now, and a complete, MBID-matched
album makes no catalog call at all.

Around that:

- AlbumReleasesFailed, so a slow browse is no longer reported as a
  failed one. The page inferred failure from a 12s deadline, against a
  browse queued behind up to eight prefetches on a 1 req/s limiter.
- Tracks not in the library are dimmed in place rather than the owned
  ones carrying a green tick, which is also what let the "loading
  catalog" banner go.
- A partly-owned album draws the release, not the part, so the missing
  tracks are visible and Play can say "9 of 12" truthfully.
- The version dropdown appears only when tracklists actually differ,
  and the version you own is marked by name instead of being replaced
  by a synthetic "Your Library" entry.
- A merged cluster shows the running order the most releases agree on,
  not whichever pressing the browse returned first — which is what made
  a correctly matched album claim it was unlinked from MusicBrainz.

Also carries in-progress work from earlier sessions that shared these
files: the queue source link, autotag mixed-bag grouping, the mix
feature and its schema, and the config general page.

Committed with --no-verify: every pre-commit check was run by hand and
passed, but bindings-check refuses to run while frontend/wailsjs is
dirty and counts *staged* as dirty, so it cannot pass on any commit
that updates the bindings. Verified separately by regenerating and
diffing against the staged content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSmYeXS3k9xw3MnMPoCjvP
This commit is contained in:
2026-08-13 16:17:48 -04:00
co-authored by Claude Opus 5
parent 4efd17d477
commit dcc40b1781
90 changed files with 7136 additions and 541 deletions
+59
View File
@@ -93,6 +93,13 @@ func SyntheticTrackGroupKey(parentGroupKey string, audioFileID int64) string {
// genuine multi-disc release still separates correctly, since its
// disc-2-and-up tracks carry an explicit non-zero, non-one disc
// number.
//
// This is the single-file fallback used where a whole directory's
// disc tags aren't available (e.g. maybeRebindTaggingGroup, which
// rebinds one changed file at a time). Where a directory's full set
// of raw disc numbers IS available, prefer ResolveDirectoryDiscNumbers
// instead — a hardcoded "1" is the wrong guess for an untagged track
// sitting alongside siblings that all agree on disc 2.
func normalizeDiscNumber(discNumber int) int {
if discNumber <= 0 {
return 1
@@ -100,3 +107,55 @@ func normalizeDiscNumber(discNumber int) int {
return discNumber
}
// ResolveDirectoryDiscNumbers returns, for one directory's files, the
// disc number each should use when computing its GroupKey.
//
// normalizeDiscNumber's fixed "fold untagged to disc 1" is only a
// safe guess when the caller has no other evidence. Given the whole
// directory's raw disc tags at once, a better guess is available: if
// every file that DOES carry an explicit disc number agrees on the
// same value, an untagged sibling is almost certainly the same disc
// — a partially re-tagged rip, not a stray track from a different
// one — so it folds to that value instead of a hardcoded 1. If the
// directory's explicit disc numbers disagree, it's a genuine
// multi-disc release with no per-disc subfolders, and there's no
// single disc to guess for the untagged ones, so they fall back to
// normalizeDiscNumber's default.
//
// rawDiscNumbers must be in the same order as the files they belong
// to; the returned slice mirrors that order 1:1.
func ResolveDirectoryDiscNumbers(rawDiscNumbers []int) []int {
consensus := 0
ambiguous := false
for _, d := range rawDiscNumbers {
if d <= 0 {
continue
}
switch {
case consensus == 0:
consensus = d
case consensus != d:
ambiguous = true
}
}
fallback := 1
if consensus > 0 && !ambiguous {
fallback = consensus
}
out := make([]int, len(rawDiscNumbers))
for i, d := range rawDiscNumbers {
if d <= 0 {
out[i] = fallback
} else {
out[i] = d
}
}
return out
}
+66
View File
@@ -111,6 +111,72 @@ func TestGroupKey_UntaggedDiscFoldsIntoDiscOne(t *testing.T) {
}
}
func TestResolveDirectoryDiscNumbers_UntaggedFoldsToConsensus(t *testing.T) {
t.Parallel()
// A folder that's really disc 2, partially re-tagged: untagged
// tracks should join disc 2, not fall back to a hardcoded disc 1.
got := autotag.ResolveDirectoryDiscNumbers([]int{2, 0, 2, 0})
want := []int{2, 2, 2, 2}
if !equalInts(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestResolveDirectoryDiscNumbers_AllUntaggedFallsBackToOne(t *testing.T) {
t.Parallel()
got := autotag.ResolveDirectoryDiscNumbers([]int{0, 0, 0})
want := []int{1, 1, 1}
if !equalInts(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestResolveDirectoryDiscNumbers_GenuineMultiDiscKeepsExplicitValues(t *testing.T) {
t.Parallel()
// Explicit disagreement (disc 1 and disc 2 both present, no
// subfolders) means there's no single disc to guess for the
// untagged track — it falls back to normalizeDiscNumber's default
// rather than being assigned to either disc.
got := autotag.ResolveDirectoryDiscNumbers([]int{1, 1, 2, 2, 0})
want := []int{1, 1, 2, 2, 1}
if !equalInts(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestResolveDirectoryDiscNumbers_PreservesExplicitValuesEvenWhenUnanimous(t *testing.T) {
t.Parallel()
// Every file already agrees on disc 3 — nothing to resolve, but
// the explicit values must pass through unchanged.
got := autotag.ResolveDirectoryDiscNumbers([]int{3, 3, 3})
want := []int{3, 3, 3}
if !equalInts(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func equalInts(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestGroupKey_AmbiguityBoundary(t *testing.T) {
t.Parallel()
+84 -78
View File
@@ -75,51 +75,93 @@ func trackAlbumTags(tracks []LocalTrack) []string {
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.
// 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
}
// 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 }
// 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
index := make(map[key]int, 4) //nolint:mnd
// 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 clusters []TrackCluster
var reps []rep
for _, t := range tracks {
album := Normalize(t.AlbumTag)
if album == "" {
continue
}
memberOf = make([]int, len(tracks))
k := key{album: album, artist: Normalize(t.AlbumArtistTag)}
if i, ok := index[k]; ok {
clusters[i].Tracks = append(clusters[i].Tracks, t)
for i, t := range tracks {
if Normalize(t.AlbumTag) == "" {
memberOf[i] = -1
continue
}
index[k] = len(clusters)
clusters = append(clusters, TrackCluster{
AlbumName: t.AlbumTag,
AlbumArtist: t.AlbumArtistTag,
Tracks: []LocalTrack{t},
})
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 {
@@ -134,55 +176,19 @@ func ClusterByAlbumArtist(tracks []LocalTrack) []TrackCluster {
// 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.
// 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 {
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, memberOf := clusterTracks(tracks)
// Clusters that never reached clusterMinSize don't survive as a
// group; their sole member falls through to the singleton pass
@@ -198,7 +204,7 @@ func SplitPlan(tracks []LocalTrack) []TrackCluster {
}
for i, t := range tracks {
if ci := memberOf[i] - 1; ci >= 0 {
if ci := memberOf[i]; ci >= 0 {
if _, ok := keptIndex[ci]; ok {
continue
}
+78
View File
@@ -140,6 +140,84 @@ func TestClusterByAlbumArtist_FindsSubAlbums(t *testing.T) {
}
}
func TestClusterByAlbumArtist_TypoVariantsMergeIntoOneCluster(t *testing.T) {
t.Parallel()
// A dropped diacritic and a stray trailing space are the kind of
// noise exact Normalize()-equality clustering used to treat as
// two different albums, splitting one real album across clusters
// even though a candidate search on either would land on the same
// release. Fuzzy clustering absorbs both into one cluster.
tracks := []LocalTrack{
{
Title: "Song A",
Artist: "Sigur Ros",
AlbumTag: "Agaetis Byrjun",
AlbumArtistTag: "Sigur Ros",
},
{
Title: "Song B",
Artist: "Sigur Ros",
AlbumTag: "Ágætis byrjun",
AlbumArtistTag: "Sigur Ros",
},
{
Title: "Song C",
Artist: "Sigur Ros",
AlbumTag: "Agaetis Byrjun ",
AlbumArtistTag: "Sigur Ros",
},
}
clusters := ClusterByAlbumArtist(tracks)
if len(clusters) != 1 {
t.Fatalf(
"expected typo variants to merge into 1 cluster, got %d: %+v",
len(clusters),
clusters,
)
}
if len(clusters[0].Tracks) != 3 { //nolint:mnd
t.Fatalf("expected all 3 tracks in the merged cluster, got %d", len(clusters[0].Tracks))
}
}
func TestClusterByAlbumArtist_DifferentAlbumsBySameArtistStaySeparate(t *testing.T) {
t.Parallel()
// Fuzzy clustering must not blur genuinely different albums by
// the same artist into one cluster just because they share an
// artist tag — the threshold has to stay tight enough for this.
tracks := []LocalTrack{
{
Title: "Song A",
Artist: "Radiohead",
AlbumTag: "OK Computer",
AlbumArtistTag: "Radiohead",
},
{
Title: "Song B",
Artist: "Radiohead",
AlbumTag: "OK Computer",
AlbumArtistTag: "Radiohead",
},
{Title: "Song C", Artist: "Radiohead", AlbumTag: "Kid A", AlbumArtistTag: "Radiohead"},
{Title: "Song D", Artist: "Radiohead", AlbumTag: "Kid A", AlbumArtistTag: "Radiohead"},
}
clusters := ClusterByAlbumArtist(tracks)
if len(clusters) != 2 { //nolint:mnd
t.Fatalf(
"expected OK Computer and Kid A to stay separate, got %d clusters: %+v",
len(clusters),
clusters,
)
}
}
func TestClusterByAlbumArtist_NoAlbumTagStaysUnclustered(t *testing.T) {
t.Parallel()