// 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 }