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
162 lines
5.6 KiB
Go
162 lines
5.6 KiB
Go
// Package autotag provides the shared primitives used by the
|
|
// MusicBrainz autotagger: deterministic album-group keys, scoring,
|
|
// and (in later phases) MB orchestration.
|
|
package autotag
|
|
|
|
import (
|
|
"crypto/sha1" //nolint:gosec // non-crypto deterministic grouping key.
|
|
"encoding/hex"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// GroupKey returns a deterministic album-group identifier for the
|
|
// file at filePath belonging to libraryID, carrying the given disc
|
|
// number.
|
|
//
|
|
// The key is a lower-case hex SHA-1 over
|
|
//
|
|
// libraryID || 0 || normalized_parent_dir || 0 || disc_number
|
|
//
|
|
// 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
|
|
// cardinality is irrelevant.
|
|
func GroupKey(
|
|
libraryID int64,
|
|
filePath string,
|
|
discNumber int,
|
|
) string {
|
|
parentDir := strings.ToLower(filepath.Dir(filePath))
|
|
|
|
h := sha1.New() //nolint:gosec // see package doc — grouping only.
|
|
h.Write([]byte(strconv.FormatInt(libraryID, 10)))
|
|
h.Write([]byte{0})
|
|
h.Write([]byte(parentDir))
|
|
h.Write([]byte{0})
|
|
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.
|
|
//
|
|
// 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
|
|
}
|
|
|
|
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
|
|
}
|