wip on autotagging

This commit is contained in:
2026-05-01 11:52:50 -04:00
parent 5cf019a0ac
commit d5140395da
295 changed files with 11105 additions and 40714 deletions
+153
View File
@@ -0,0 +1,153 @@
package autotag
// alignTitleFloor is the minimum title similarity required to
// pair a local file with a candidate track at all. Below this,
// even the best available pair is treated as no pair: the local
// stays "unmatched" and the candidate slot stays "missing". Set
// well below titleReject so a local file with the wrong track
// number but mostly-correct title still pairs (and surfaces the
// number mismatch as a diff), while a totally unrelated file that
// happens to share a track number stays in its own group.
const alignTitleFloor = 0.30
// AlignTracks pairs local tracks with candidate tracks using a
// greedy best-score algorithm: repeatedly pick the (local, cand)
// pair with the highest trackDistance that hasn't already been
// claimed. Not optimal (Hungarian would be), but good enough for
// the small cardinalities we see (album tracks, ~10-50) and much
// simpler.
//
// Pairs whose title similarity is below alignTitleFloor are
// rejected: the local stays "unmatched" and the candidate slot
// surfaces as "missing". This lets a wrong-track-number-but-
// matching-title file pair correctly while keeping a totally
// unrelated file from being force-paired into a slot.
//
// Returns one TrackAlignment per local track (status = matched,
// mismatched, or unmatched) plus additional missing alignments
// for candidate tracks with no local file. The caller sums Score
// fields to get a release-level score.
func AlignTracks(locals []LocalTrack, cands []CandidateTrack) []TrackAlignment {
type pair struct {
li int
ci int
score float64
title float64
}
// Score every (local, cand) combination.
pairs := make([]pair, 0, len(locals)*len(cands))
for li, local := range locals {
for ci, cand := range cands {
pairs = append(pairs, pair{
li: li,
ci: ci,
score: trackDistance(local, cand),
title: titleSimilarity(local.Title, cand.Title),
})
}
}
// Sort descending by score — pick best pairs first. Insertion
// sort keeps the dependency surface zero; N^2 here is fine for
// album-sized inputs.
for i := 1; i < len(pairs); i++ {
for j := i; j > 0 && pairs[j].score > pairs[j-1].score; j-- {
pairs[j], pairs[j-1] = pairs[j-1], pairs[j]
}
}
localUsed := make([]bool, len(locals))
candUsed := make([]bool, len(cands))
alignments := make([]TrackAlignment, len(locals))
localMatched := 0
for _, p := range pairs {
if localMatched == len(locals) {
break
}
if localUsed[p.li] || candUsed[p.ci] {
continue
}
// Below the floor: this is the best pair available for both
// of these slots, but the title similarity is so low that
// pairing them would just be noise. Leave both unclaimed —
// they'll fall through to the unmatched/missing fixups below.
if p.title < alignTitleFloor {
continue
}
localUsed[p.li] = true
candUsed[p.ci] = true
localMatched++
l := locals[p.li]
c := cands[p.ci]
status := AlignmentMatched
if p.title < titleReject {
status = AlignmentMismatched
}
delta := l.LengthMillis - c.LengthMillis
if delta < 0 {
delta = -delta
}
alignments[p.li] = TrackAlignment{
LocalIndex: p.li,
LocalTitle: l.Title,
LocalLengthMillis: l.LengthMillis,
CandidatePosition: c.Position,
CandidateDiscNumber: c.DiscNumber,
CandidateTitle: c.Title,
CandidateMBID: c.MBID,
CandidateLength: c.LengthMillis,
TitleScore: p.title,
LengthDeltaMs: delta,
TrackNumberOK: l.TrackNumber > 0 && l.TrackNumber == c.Position,
Status: status,
}
}
// Local tracks left unclaimed → folder has them, candidate
// doesn't (or pairing was rejected by the floor).
for li, used := range localUsed {
if used {
continue
}
l := locals[li]
alignments[li] = TrackAlignment{
LocalIndex: li,
LocalTitle: l.Title,
LocalLengthMillis: l.LengthMillis,
Status: AlignmentUnmatched,
}
}
// Candidate tracks left unclaimed → candidate has them, folder
// doesn't.
for ci, used := range candUsed {
if used {
continue
}
c := cands[ci]
alignments = append(alignments, TrackAlignment{
LocalIndex: -1,
CandidatePosition: c.Position,
CandidateDiscNumber: c.DiscNumber,
CandidateTitle: c.Title,
CandidateMBID: c.MBID,
CandidateLength: c.LengthMillis,
Status: AlignmentMissing,
})
}
return alignments
}
+178
View File
@@ -0,0 +1,178 @@
package autotag_test
import (
"testing"
"yellowjacket/backend/autotag"
)
func TestAlignTracks_ExactMatch(t *testing.T) {
t.Parallel()
local := []autotag.LocalTrack{
{Title: "Come Together", TrackNumber: 1, LengthMillis: 259000},
{Title: "Something", TrackNumber: 2, LengthMillis: 183000},
}
cand := []autotag.CandidateTrack{
{Position: 1, Title: "Come Together", LengthMillis: 259000},
{Position: 2, Title: "Something", LengthMillis: 183000},
}
al := autotag.AlignTracks(local, cand)
if len(al) != 2 { //nolint:mnd
t.Fatalf("alignments = %d, want 2", len(al))
}
for i, a := range al {
if a.Status != autotag.AlignmentMatched {
t.Errorf("alignment %d: status = %q, want matched", i, a.Status)
}
if a.LocalIndex != i {
t.Errorf("alignment %d: LocalIndex = %d", i, a.LocalIndex)
}
}
}
// Folder has 2 tracks, candidate has 3 — the third candidate
// track surfaces as "missing" (candidate has it, folder doesn't).
func TestAlignTracks_CandidateMissingTrack(t *testing.T) {
t.Parallel()
local := []autotag.LocalTrack{
{Title: "Come Together", TrackNumber: 1, LengthMillis: 259000},
{Title: "Something", TrackNumber: 2, LengthMillis: 183000},
}
cand := []autotag.CandidateTrack{
{Position: 1, Title: "Come Together", LengthMillis: 259000},
{Position: 2, Title: "Something", LengthMillis: 183000},
{Position: 3, Title: "Here Comes the Sun", LengthMillis: 185000},
}
al := autotag.AlignTracks(local, cand)
if len(al) != 3 { //nolint:mnd
t.Fatalf("alignments = %d, want 3 (2 matched + 1 missing)", len(al))
}
var missing, matched int
for _, a := range al {
switch a.Status {
case autotag.AlignmentMatched:
matched++
case autotag.AlignmentMissing:
missing++
if a.CandidateTitle != "Here Comes the Sun" {
t.Errorf("missing alignment title = %q", a.CandidateTitle)
}
case autotag.AlignmentUnmatched, autotag.AlignmentMismatched:
t.Errorf("unexpected status %q", a.Status)
}
}
if matched != 2 || missing != 1 { //nolint:mnd
t.Errorf("matched=%d missing=%d, want 2/1", matched, missing)
}
}
// One local track has a low-but-non-zero similarity to the only
// candidate slot (above the floor, below titleReject) — pairs up
// as "mismatched" so the UI can flag the diff.
func TestAlignTracks_Mismatched(t *testing.T) {
t.Parallel()
// "yellow" vs "yellow submarine" normalises to a similarity of
// ~0.375 — above alignTitleFloor (0.30), below titleReject
// (0.60).
local := []autotag.LocalTrack{
{Title: "Yellow", TrackNumber: 1, LengthMillis: 100000},
}
cand := []autotag.CandidateTrack{
{Position: 1, Title: "Yellow Submarine", LengthMillis: 100000},
}
al := autotag.AlignTracks(local, cand)
if len(al) != 1 {
t.Fatalf("alignments = %d, want 1", len(al))
}
if al[0].Status != autotag.AlignmentMismatched {
t.Errorf("status = %q, want mismatched", al[0].Status)
}
}
// A folder track that is genuinely unrelated to anything in the
// candidate list should surface as "unmatched" rather than getting
// force-paired into a slot, even when only one candidate slot is
// available. The candidate slot becomes "missing" in turn.
func TestAlignTracks_RandomLocalTrack(t *testing.T) {
t.Parallel()
// Title similarity here is well below alignTitleFloor (0.30) —
// no shared word stems, no shared length, no shared structure.
local := []autotag.LocalTrack{
{Title: "Random Garbage Title", TrackNumber: 1, LengthMillis: 100000},
}
cand := []autotag.CandidateTrack{
{Position: 1, Title: "Specific Album Track", LengthMillis: 259000},
}
al := autotag.AlignTracks(local, cand)
if len(al) != 2 { //nolint:mnd
t.Fatalf("alignments = %d, want 2 (1 unmatched + 1 missing)", len(al))
}
var unmatched, missing int
for _, a := range al {
switch a.Status {
case autotag.AlignmentUnmatched:
unmatched++
case autotag.AlignmentMissing:
missing++
default:
t.Errorf("unexpected status %q", a.Status)
}
}
if unmatched != 1 || missing != 1 {
t.Errorf("unmatched=%d missing=%d, want 1/1", unmatched, missing)
}
}
// A folder track with the wrong track number but a matching title
// + length should still pair up — the wrong number becomes a diff
// the UI surfaces, not a reason to refuse pairing.
func TestAlignTracks_WrongTrackNumberStillPairs(t *testing.T) {
t.Parallel()
local := []autotag.LocalTrack{
{Title: "Come Together", TrackNumber: 7, LengthMillis: 259000},
}
cand := []autotag.CandidateTrack{
{Position: 1, Title: "Come Together", LengthMillis: 259000},
}
al := autotag.AlignTracks(local, cand)
if len(al) != 1 {
t.Fatalf("alignments = %d, want 1", len(al))
}
if al[0].Status != autotag.AlignmentMatched {
t.Errorf("status = %q, want matched (track-number diff is not a reject)", al[0].Status)
}
if al[0].TrackNumberOK {
t.Errorf("TrackNumberOK = true, want false (local#=7 vs candidate#=1)")
}
if al[0].CandidatePosition != 1 {
t.Errorf("CandidatePosition = %d, want 1", al[0].CandidatePosition)
}
}
+423
View File
@@ -0,0 +1,423 @@
package autotag
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"yellowjacket/backend/database/sql/sqlcgen"
)
// TagChanges mirrors tagwriter.TagChanges — redefined here so the
// autotag package does not import tagwriter (and so tests can
// construct changes without pulling in the write pipeline). The
// adapter in app wiring converts this to the concrete tagwriter
// shape.
type TagChanges map[string]any
// Field name constants matching tagwriter's canonical names. Keep
// these in sync with tagwriter/tagwriter.go — the runtime adapter
// passes the map through unchanged.
const (
FieldTitle = "title"
FieldArtist = "artist"
FieldAlbum = "album"
FieldAlbumArtist = "album_artist"
FieldYear = "year"
FieldTrackNumber = "track_number"
FieldDiscNumber = "disc_number"
FieldCoverArt = "cover_art"
)
// TagWriter is the subset of tagwriter.TagWriter the apply pipeline
// needs. Defined here so scorer_test.go can stub it and autotag
// stays import-acyclic with tagwriter.
type TagWriter interface {
WriteTrackTagsByPath(filePath string, changes TagChanges) error
}
// ApplyPlan summarises what Apply is about to do for one group.
// Returned from PrepareApply so the UI can preview (and, in the
// future, pass through a dry-run flag).
type ApplyPlan struct {
GroupKey string
Candidate Candidate
Tracks []TrackApply
}
// TrackApply is the per-track slice of an ApplyPlan: the local
// audio file, the aligned candidate track, and the changes the
// writer will actually emit.
type TrackApply struct {
Local LocalTrack
CandidateTrack CandidateTrack
Changes TagChanges
Aligned bool // false when no candidate track matched (skipped)
}
// ApplyResult summarises the outcome after the writes ran.
type ApplyResult struct {
GroupKey string
Succeeded int
Failed int
Failures []ApplyFailure
}
// ApplyFailure captures one track that failed during apply.
type ApplyFailure struct {
FilePath string
Error string
}
// ErrNoCandidate is returned when Apply is asked to apply a release
// MBID that isn't in the group's current candidate list.
var ErrNoCandidate = errors.New("autotag: candidate not found for apply")
// Applier runs the per-track tag writes + DB MBID updates when the
// user accepts a candidate. Cover-art embedding is delegated to a
// separate helper (CoverArtEmbedder) that's optional — tests pass
// nil to skip the CAA path.
type Applier struct {
q *sqlcgen.Queries
tw TagWriter
coverArt CoverArtEmbedder
log *slog.Logger
}
// CoverArtEmbedder is the autotag pipeline's view of cover-art
// fetching. Split into two operations so the Applier can fetch
// the release group's art exactly once per album and only consult
// each file's existing-art state per-track:
//
// - FetchArt is a network operation: hit CAA for the release
// group, validate dimensions, return the bytes ready to embed
// (or nil when CAA has nothing / the result is below the
// minimum size). Idempotent — call once per album.
// - HasEmbeddedArt is a per-file probe: read the local file's
// tags and report whether it already carries a picture. Cheap
// compared to FetchArt; called per track.
//
// The Applier merges FieldCoverArt into a track's changes only
// when FetchArt produced bytes AND HasEmbeddedArt returned false
// for that track — preserving the rule "never replace existing
// art".
type CoverArtEmbedder interface {
FetchArt(ctx context.Context, releaseGroupMBID string) ([]byte, error)
HasEmbeddedArt(filePath string) bool
}
// NewApplier wires up the apply pipeline. Pass coverArt=nil to
// skip CAA integration (useful for tests).
func NewApplier(
q *sqlcgen.Queries,
tw TagWriter,
coverArt CoverArtEmbedder,
logger *slog.Logger,
) *Applier {
return &Applier{q: q, tw: tw, coverArt: coverArt, log: logger}
}
// BuildPlan constructs the per-track change set for a given
// candidate without executing any writes. The UI can call this to
// preview the diff before confirming. When releaseMBID doesn't
// match any candidate in score, ErrNoCandidate is returned.
func (a *Applier) BuildPlan(
score *GroupScore, releaseMBID string,
) (*ApplyPlan, error) {
var picked *Candidate
for i := range score.Candidates {
c := &score.Candidates[i]
if c.ReleaseMBID == releaseMBID ||
(releaseMBID == "" && i == 0) ||
(c.ReleaseMBID == "" && c.ReleaseGroupMBID == releaseMBID) {
picked = c
break
}
}
if picked == nil {
return nil, ErrNoCandidate
}
tracks := make([]TrackApply, 0, len(score.LocalTracks))
for li, local := range score.LocalTracks {
align := findAlignment(picked.Alignments, li)
if align == nil || align.Status == AlignmentUnmatched {
tracks = append(tracks, TrackApply{Local: local, Aligned: false})
continue
}
candTrack := CandidateTrack{
Position: align.CandidatePosition,
DiscNumber: align.CandidateDiscNumber,
Title: align.CandidateTitle,
LengthMillis: align.CandidateLength,
MBID: align.CandidateMBID,
}
tracks = append(tracks, TrackApply{
Local: local,
CandidateTrack: candTrack,
Changes: buildChanges(local, *picked, candTrack),
Aligned: true,
})
}
return &ApplyPlan{
GroupKey: score.GroupKey,
Candidate: *picked,
Tracks: tracks,
}, nil
}
// ApplyProgress is the optional per-track callback Apply invokes
// after each track is processed. Hosts pass nil to opt out (used
// in tests and the legacy synchronous Apply path); the
// autotagservice layer wires this to a Wails event emit so the
// review UI can render a progress ring.
//
// counts: current is 1-indexed (the track that just finished),
// total is len(aligned tracks), succeeded/failed are running
// totals.
type ApplyProgress func(current, total, succeeded, failed int)
// Apply runs the plan: for each aligned track, write file tags,
// update DB MBID columns, stamp audio_files.tag_status =
// 'user_confirmed'. Cover art is fetched once for the whole
// album (idempotent) and merged into each track that has no
// existing embedded art. Tracks whose changes map ends up empty
// are skipped silently and counted as succeeded — that keeps a
// "Leave as-is on a perfect match" path from spuriously failing
// every track on errNoChanges.
//
// onProgress is called once per aligned track as it completes
// (success or failure); pass nil to opt out.
//
// On completion, tagging_items status flips to 'confirmed' if at
// least one track succeeded; failed-everything jobs leave the
// group in 'pending' so it remains in the review queue.
func (a *Applier) Apply(
ctx context.Context, plan *ApplyPlan, onProgress ApplyProgress,
) (*ApplyResult, error) {
result := &ApplyResult{GroupKey: plan.GroupKey}
// Fetch the release-group's cover art once up front — same
// album, same JPEG, no point hitting CAA per track. The
// per-file existing-art check still runs inside the loop so
// we never overwrite an existing picture.
var albumArt []byte
if a.coverArt != nil && plan.Candidate.ReleaseGroupMBID != "" {
art, err := a.coverArt.FetchArt(ctx, plan.Candidate.ReleaseGroupMBID)
if err != nil {
a.log.Warn(
"cover art fetch failed — proceeding without embed",
"release_group_mbid", plan.Candidate.ReleaseGroupMBID, "err", err,
)
} else {
albumArt = art
}
}
// Total of aligned tracks for progress reporting.
total := 0
for _, tr := range plan.Tracks {
if tr.Aligned {
total++
}
}
current := 0
for _, tr := range plan.Tracks {
if !tr.Aligned {
continue
}
current++
changes := tr.Changes
if albumArt != nil && a.coverArt != nil && !a.coverArt.HasEmbeddedArt(tr.Local.FilePath) {
// Copy the map so we don't mutate a slice of shared
// maps on subsequent iterations.
merged := make(TagChanges, len(changes)+1)
for k, v := range changes {
merged[k] = v
}
merged[FieldCoverArt] = albumArt
changes = merged
}
// Skip the file write entirely when no field would change.
// The tagwriter would return errNoChanges and we'd record
// it as a failure — wrong outcome for a track that's
// already correct. The DB sync still runs so MBIDs land.
if len(changes) > 0 {
if err := a.tw.WriteTrackTagsByPath(tr.Local.FilePath, changes); err != nil {
result.Failed++
result.Failures = append(result.Failures, ApplyFailure{
FilePath: tr.Local.FilePath,
Error: err.Error(),
})
a.log.Warn(
"apply: tag write failed",
"path", tr.Local.FilePath, "err", err,
)
if onProgress != nil {
onProgress(current, total, result.Succeeded, result.Failed)
}
continue
}
}
if err := a.syncDBMBIDs(ctx, tr, plan.Candidate); err != nil {
a.log.Warn(
"apply: MBID DB sync failed (file tags written OK)",
"path", tr.Local.FilePath, "err", err,
)
}
if err := a.q.SetAudioFileTagStatus(ctx, sqlcgen.SetAudioFileTagStatusParams{
TagStatus: "user_confirmed",
ID: tr.Local.AudioFileID,
}); err != nil {
a.log.Warn(
"apply: tag_status update failed",
"audio_file_id", tr.Local.AudioFileID, "err", err,
)
}
result.Succeeded++
if onProgress != nil {
onProgress(current, total, result.Succeeded, result.Failed)
}
}
// Flip the group's status only when at least one write landed.
if result.Succeeded > 0 {
if err := a.q.SetTaggingItemStatus(ctx, sqlcgen.SetTaggingItemStatusParams{
Status: "confirmed",
GroupKey: plan.GroupKey,
}); err != nil {
return result, fmt.Errorf("mark group confirmed: %w", err)
}
}
return result, nil
}
// syncDBMBIDs writes the recording + release-group MBIDs from the
// candidate to the local DB so the app's MBID-driven features (MB
// browser "in library" indicator, smart-playlist MBID rule fields)
// pick up the new identity without needing a rescan. File-level
// MBID frames are *not* written by tagwriter today — they're a
// follow-up; the DB copy is what the app reads.
func (a *Applier) syncDBMBIDs(
ctx context.Context, tr TrackApply, cand Candidate,
) error {
// Look up recording row via audio_file.
af, err := a.q.GetAudioFile(ctx, tr.Local.AudioFileID)
if err != nil {
return fmt.Errorf("get audio_file: %w", err)
}
if tr.CandidateTrack.MBID != "" {
if err := a.q.SetRecordingMBID(ctx, sqlcgen.SetRecordingMBIDParams{
Mbid: sql.NullString{String: tr.CandidateTrack.MBID, Valid: true},
ID: af.RecordingID,
}); err != nil {
return fmt.Errorf("set recording mbid: %w", err)
}
}
if cand.ReleaseGroupMBID != "" {
rgID, err := a.q.GetRecordingReleaseGroupID(ctx, af.RecordingID)
if err == nil && rgID > 0 {
if err := a.q.SetReleaseGroupMBID(ctx, sqlcgen.SetReleaseGroupMBIDParams{
Mbid: sql.NullString{String: cand.ReleaseGroupMBID, Valid: true},
ID: rgID,
}); err != nil {
return fmt.Errorf("set release group mbid: %w", err)
}
// Stamp the release-group's original-release year too —
// this is what the tracklist / smart-playlist year rule
// surfaces by default once the user accepts a candidate.
if year := parseYear(cand.OriginalDate); year > 0 {
if err := a.q.SetReleaseGroupOriginalYear(
ctx, sqlcgen.SetReleaseGroupOriginalYearParams{
OriginalYear: sql.NullInt64{Int64: int64(year), Valid: true},
ID: rgID,
},
); err != nil {
return fmt.Errorf("set release group original year: %w", err)
}
}
}
}
return nil
}
// findAlignment returns the alignment row that corresponds to the
// given local-track index, or nil if the track wasn't aligned
// (status=missing).
func findAlignment(alignments []TrackAlignment, localIdx int) *TrackAlignment {
for i := range alignments {
if alignments[i].LocalIndex == localIdx {
return &alignments[i]
}
}
return nil
}
// buildChanges returns the whitelisted field diff for one track
// given its aligned candidate track and the parent release. Only
// non-empty candidate values become changes — we don't overwrite
// local tags with empty strings from incomplete MB data.
func buildChanges(
local LocalTrack, cand Candidate, track CandidateTrack,
) TagChanges {
changes := make(TagChanges, 8) //nolint:mnd
if track.Title != "" && track.Title != local.Title {
changes[FieldTitle] = track.Title
}
if cand.ArtistCredit != "" {
changes[FieldArtist] = cand.ArtistCredit
changes[FieldAlbumArtist] = cand.ArtistCredit
}
if cand.Title != "" {
changes[FieldAlbum] = cand.Title
}
if year := parseYear(cand.Date); year > 0 {
changes[FieldYear] = year
}
if track.Position > 0 && track.Position != local.TrackNumber {
changes[FieldTrackNumber] = track.Position
}
if track.DiscNumber > 0 && track.DiscNumber != local.DiscNumber {
changes[FieldDiscNumber] = track.DiscNumber
}
return changes
}
+383
View File
@@ -0,0 +1,383 @@
package autotag_test
import (
"context"
"database/sql"
"log/slog"
"sync"
"testing"
"yellowjacket/backend/autotag"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
)
// recordingTagWriter is a TagWriter stub that records each call's
// path + changes for assertion. Returns nil from
// WriteTrackTagsByPath unless writeErr is set, in which case every
// call returns it.
type recordingTagWriter struct {
mu sync.Mutex
calls []recordedTagWrite
writeErr error
}
type recordedTagWrite struct {
filePath string
changes autotag.TagChanges
}
func (w *recordingTagWriter) WriteTrackTagsByPath(
filePath string, changes autotag.TagChanges,
) error {
w.mu.Lock()
defer w.mu.Unlock()
w.calls = append(w.calls, recordedTagWrite{filePath: filePath, changes: changes})
return w.writeErr
}
// stubCoverArt is a CoverArtEmbedder stub that counts calls so we
// can verify FetchArt runs exactly once per Apply. hasEmbedded
// is the response HasEmbeddedArt returns for every file; art is
// what FetchArt returns.
type stubCoverArt struct {
mu sync.Mutex
fetchCalls int
embeddedCalls int
art []byte
hasEmbedded bool
releaseSeen string
}
func (s *stubCoverArt) FetchArt(_ context.Context, releaseGroupMBID string) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.fetchCalls++
s.releaseSeen = releaseGroupMBID
return s.art, nil
}
func (s *stubCoverArt) HasEmbeddedArt(_ string) bool {
s.mu.Lock()
defer s.mu.Unlock()
s.embeddedCalls++
return s.hasEmbedded
}
// silentLogger swallows log output during tests.
func silentLogger() *slog.Logger {
return slog.New(slog.DiscardHandler)
}
// seedAudioFiles inserts the minimum DB rows the apply pipeline
// touches: artist credit, recordings, release group, RG-recording
// links, and audio_files. Returns the audio_file IDs in track
// order so the test can build matching ApplyPlan entries.
func seedAudioFiles(
t *testing.T, db *database.DB, groupKey string, paths []string,
) []sqlcgen.AudioFile {
t.Helper()
q := db.Queries
ctx := db.Ctx
ac, err := q.UpsertArtistCredit(ctx, "Test Artist")
if err != nil {
t.Fatalf("upsert artist credit: %v", err)
}
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
Name: "Test Album",
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
})
if err != nil {
t.Fatalf("upsert rg: %v", err)
}
out := make([]sqlcgen.AudioFile, 0, len(paths))
for i, p := range paths {
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
Name: p,
ArtistCreditID: ac.ID,
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
})
if err != nil {
t.Fatalf("create recording: %v", err)
}
if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{
ReleaseGroupID: rg.ID,
RecordingID: rec.ID,
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
}); err != nil {
t.Fatalf("link rg recording: %v", err)
}
af, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
FilePath: p,
LengthMilliseconds: 100000,
FileTypeID: 0,
RecordingID: rec.ID,
Basename: p,
LibraryID: 0,
GroupKey: groupKey,
TagStatus: "untagged",
})
if err != nil {
t.Fatalf("create audio file: %v", err)
}
out = append(out, af)
}
if _, err := db.ExecContext(`
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
VALUES (?, 0, ?, 'Test Album', 'Test Artist', 0, 'pending')
`, groupKey, len(paths)); err != nil {
t.Fatalf("insert tagging_item: %v", err)
}
return out
}
// TestApply_SkipsNoOpChanges verifies that a track whose Changes
// map is empty (nothing would change in the file) doesn't trigger
// a WriteTrackTagsByPath call but still counts as a successful
// track in the result. Today the writer would return errNoChanges
// for an empty changes map; the apply would record a spurious
// failure. This test pins the new behaviour: skip cleanly.
func TestApply_SkipsNoOpChanges(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
files := seedAudioFiles(t, db, "g-noop", []string{"/tmp/a.mp3", "/tmp/b.mp3"})
tw := &recordingTagWriter{}
cover := &stubCoverArt{} // no art to embed
applier := autotag.NewApplier(db.Queries, tw, cover, silentLogger())
plan := &autotag.ApplyPlan{
GroupKey: "g-noop",
Candidate: autotag.Candidate{Title: "Test Album"}, // no RG MBID → no cover fetch
Tracks: []autotag.TrackApply{
{
Local: autotag.LocalTrack{
AudioFileID: files[0].ID, FilePath: "/tmp/a.mp3",
},
Changes: autotag.TagChanges{},
Aligned: true,
},
{
Local: autotag.LocalTrack{
AudioFileID: files[1].ID, FilePath: "/tmp/b.mp3",
},
Changes: autotag.TagChanges{autotag.FieldTitle: "New"},
Aligned: true,
},
},
}
result, err := applier.Apply(context.Background(), plan, nil)
if err != nil {
t.Fatalf("apply: %v", err)
}
if result.Succeeded != 2 { //nolint:mnd
t.Errorf("succeeded = %d, want 2", result.Succeeded)
}
if result.Failed != 0 {
t.Errorf("failed = %d, want 0", result.Failed)
}
if len(tw.calls) != 1 {
t.Errorf("WriteTrackTagsByPath calls = %d, want 1 (no-op skipped)", len(tw.calls))
}
if len(tw.calls) > 0 && tw.calls[0].filePath != "/tmp/b.mp3" {
t.Errorf("call 0 path = %q, want /tmp/b.mp3 (the only one with changes)",
tw.calls[0].filePath)
}
}
// TestApply_FetchArtCalledOncePerAlbum verifies that the cover-art
// network fetch happens exactly once per Apply, regardless of how
// many tracks need it. HasEmbeddedArt is still called per track
// so we never overwrite existing art.
func TestApply_FetchArtCalledOncePerAlbum(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
paths := []string{"/tmp/x1.mp3", "/tmp/x2.mp3", "/tmp/x3.mp3", "/tmp/x4.mp3"}
files := seedAudioFiles(t, db, "g-art", paths)
tw := &recordingTagWriter{}
cover := &stubCoverArt{art: []byte("fake-jpeg")} // network bytes available
applier := autotag.NewApplier(db.Queries, tw, cover, silentLogger())
tracks := make([]autotag.TrackApply, 0, len(paths))
for i, p := range paths {
tracks = append(tracks, autotag.TrackApply{
Local: autotag.LocalTrack{
AudioFileID: files[i].ID, FilePath: p,
},
Changes: autotag.TagChanges{autotag.FieldTitle: "T"},
Aligned: true,
})
}
plan := &autotag.ApplyPlan{
GroupKey: "g-art",
Candidate: autotag.Candidate{
Title: "Test Album",
ReleaseGroupMBID: "rg-fake",
},
Tracks: tracks,
}
result, err := applier.Apply(context.Background(), plan, nil)
if err != nil {
t.Fatalf("apply: %v", err)
}
if result.Succeeded != len(paths) {
t.Errorf("succeeded = %d, want %d", result.Succeeded, len(paths))
}
if cover.fetchCalls != 1 {
t.Errorf("FetchArt calls = %d, want 1 (memoised per album)", cover.fetchCalls)
}
if cover.embeddedCalls != len(paths) {
t.Errorf("HasEmbeddedArt calls = %d, want %d (one per track)",
cover.embeddedCalls, len(paths))
}
if cover.releaseSeen != "rg-fake" {
t.Errorf("FetchArt rg = %q, want rg-fake", cover.releaseSeen)
}
// Every track's WriteTrackTagsByPath call should carry the
// cover-art bytes since none of the files had embedded art.
for i, c := range tw.calls {
if _, ok := c.changes[autotag.FieldCoverArt]; !ok {
t.Errorf("call %d: no cover_art in changes", i)
}
}
}
// TestApply_SkipsCoverWhenAlreadyEmbedded verifies that
// HasEmbeddedArt=true blocks the per-track cover-art merge — never
// replace existing art is the invariant.
func TestApply_SkipsCoverWhenAlreadyEmbedded(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
files := seedAudioFiles(t, db, "g-emb", []string{"/tmp/y1.mp3"})
tw := &recordingTagWriter{}
// File reports it already has embedded art; FetchArt still
// runs (one call, then memoised) but its bytes never land in
// the change map.
cover := &stubCoverArt{art: []byte("bytes"), hasEmbedded: true}
applier := autotag.NewApplier(db.Queries, tw, cover, silentLogger())
plan := &autotag.ApplyPlan{
GroupKey: "g-emb",
Candidate: autotag.Candidate{
Title: "Test Album",
ReleaseGroupMBID: "rg-emb",
},
Tracks: []autotag.TrackApply{
{
Local: autotag.LocalTrack{
AudioFileID: files[0].ID, FilePath: "/tmp/y1.mp3",
},
Changes: autotag.TagChanges{autotag.FieldTitle: "T"},
Aligned: true,
},
},
}
if _, err := applier.Apply(context.Background(), plan, nil); err != nil {
t.Fatalf("apply: %v", err)
}
if len(tw.calls) != 1 {
t.Fatalf("write calls = %d, want 1", len(tw.calls))
}
if _, ok := tw.calls[0].changes[autotag.FieldCoverArt]; ok {
t.Errorf("cover_art present in changes; should have been skipped")
}
}
// TestApply_ProgressCallbackInvocations verifies onProgress is
// called once per aligned track and that current/total reflect
// the position in the sequence.
func TestApply_ProgressCallbackInvocations(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
paths := []string{"/tmp/p1.mp3", "/tmp/p2.mp3", "/tmp/p3.mp3"}
files := seedAudioFiles(t, db, "g-prog", paths)
applier := autotag.NewApplier(db.Queries, &recordingTagWriter{}, nil, silentLogger())
tracks := make([]autotag.TrackApply, 0, len(paths))
for i, p := range paths {
tracks = append(tracks, autotag.TrackApply{
Local: autotag.LocalTrack{
AudioFileID: files[i].ID, FilePath: p,
},
Changes: autotag.TagChanges{autotag.FieldTitle: "T"},
Aligned: true,
})
}
plan := &autotag.ApplyPlan{
GroupKey: "g-prog",
Candidate: autotag.Candidate{Title: "Test Album"},
Tracks: tracks,
}
type call struct {
current, total, succeeded, failed int
}
var calls []call
if _, err := applier.Apply(
context.Background(),
plan,
func(current, total, succeeded, failed int) {
calls = append(calls, call{current, total, succeeded, failed})
},
); err != nil {
t.Fatalf("apply: %v", err)
}
if len(calls) != len(paths) {
t.Fatalf("progress callbacks = %d, want %d", len(calls), len(paths))
}
for i, c := range calls {
if c.current != i+1 {
t.Errorf("call %d: current = %d, want %d", i, c.current, i+1)
}
if c.total != len(paths) {
t.Errorf("call %d: total = %d, want %d", i, c.total, len(paths))
}
}
}
+155
View File
@@ -0,0 +1,155 @@
package autotag
// levenshtein returns the Levenshtein edit distance between a and
// b, operating on runes so multi-byte characters count as one edit.
// Allocates a single O(min(len)) scratch slice.
func levenshtein(a, b string) int {
ra := []rune(a)
rb := []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
if len(ra) > len(rb) {
ra, rb = rb, ra
}
prev := make([]int, len(ra)+1)
for i := range prev {
prev[i] = i
}
for j := 1; j <= len(rb); j++ {
curr0 := prev[0]
prev[0] = j
for i := 1; i <= len(ra); i++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
newVal := min3(
prev[i]+1, // deletion
prev[i-1]+1, // insertion
curr0+cost, // substitution
)
curr0 = prev[i]
prev[i] = newVal
}
}
return prev[len(ra)]
}
func min3(a, b, c int) int {
if a < b {
if a < c {
return a
}
return c
}
if b < c {
return b
}
return c
}
// titleSimilarity returns a score in [0, 1] from normalized edit
// distance. 1.0 means identical after normalization, 0.0 means
// fully dissimilar. Both sides are normalized inside.
func titleSimilarity(a, b string) float64 {
na := Normalize(a)
nb := Normalize(b)
if na == "" && nb == "" {
return 1.0
}
longest := len(na)
if len(nb) > longest {
longest = len(nb)
}
if longest == 0 {
return 0.0
}
dist := levenshtein(na, nb)
return 1.0 - float64(dist)/float64(longest)
}
// Scoring weights for the per-track distance function. Local reads
// only — never written at runtime, so no mutex. Values stay small
// so a future tuning pass can nudge them without rescaling.
const (
weightTitle = 0.60
weightLength = 0.30
weightTrackNumber = 0.10
// Length deltas at or below lengthExactMs score 1.0 — matches
// the frontend's "subtle drift" threshold so anything the UI
// hides also doesn't count against the score. Past the
// threshold the penalty scales with delta / candidateMs (i.e.
// percentage of candidate-track length): a 5 s delta on a
// 4 min track is small, the same delta on a 30 s interlude is
// huge. At lengthFullyWrongPct of candidate length the score
// hits zero; beyond that it stays clamped to zero.
lengthExactMs int64 = 2000
lengthFullyWrongPct float64 = 0.20
// A title below titleReject has too little signal for this
// alignment to count as matched.
titleReject = 0.60
)
// lengthScore returns 1.0 for deltas <= lengthExactMs, 0.0 for
// deltas >= lengthFullyWrongPct of the candidate length, linear
// in delta-as-percentage-of-candidate-length between. When
// either side is zero (unknown), returns 0.5 so length is treated
// as neutral.
func lengthScore(localMs, candidateMs int64) float64 {
if localMs <= 0 || candidateMs <= 0 {
return 0.5
}
delta := localMs - candidateMs
if delta < 0 {
delta = -delta
}
if delta <= lengthExactMs {
return 1.0
}
pct := float64(delta) / float64(candidateMs)
if pct >= lengthFullyWrongPct {
return 0.0
}
return 1.0 - pct/lengthFullyWrongPct
}
// trackDistance scores how well one local track aligns with one
// candidate track. Higher is better. Caller decides what to do
// with the result — this function has no threshold.
func trackDistance(local LocalTrack, cand CandidateTrack) float64 {
title := titleSimilarity(local.Title, cand.Title)
length := lengthScore(local.LengthMillis, cand.LengthMillis)
var trackOK float64
if local.TrackNumber > 0 && local.TrackNumber == cand.Position {
trackOK = 1.0
}
return title*weightTitle + length*weightLength + trackOK*weightTrackNumber
}
+132
View File
@@ -0,0 +1,132 @@
package autotag
import "testing"
func TestLevenshtein(t *testing.T) {
t.Parallel()
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"abc", "", 3},
{"", "abc", 3},
{"kitten", "sitting", 3},
{"beyoncé", "beyonce", 1},
{"abbey road", "abbey road", 0},
}
for _, tc := range cases {
got := levenshtein(tc.a, tc.b)
if got != tc.want {
t.Errorf("levenshtein(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
func TestTitleSimilarity(t *testing.T) {
t.Parallel()
cases := []struct {
a, b string
minScore float64
}{
{"Hey Jude", "Hey Jude", 1.00},
{"Hey Jude", "Hey Jude (Remastered 2009)", 1.00}, // qualifier stripped
{"Hey Jude", "HEY JUDE!", 1.00}, // case + punct
{"Hey Jude", "Hay Jude", 0.85}, // one char off
{"Hey Jude", "Let It Be", 0.00}, // different
}
for _, tc := range cases {
got := titleSimilarity(tc.a, tc.b)
if tc.minScore == 0 {
if got > 0.5 { //nolint:mnd
t.Errorf("titleSimilarity(%q, %q) = %.2f, expected low", tc.a, tc.b, got)
}
continue
}
if got < tc.minScore {
t.Errorf(
"titleSimilarity(%q, %q) = %.2f, want >= %.2f",
tc.a, tc.b, got, tc.minScore,
)
}
}
}
func TestLengthScore(t *testing.T) {
t.Parallel()
cases := []struct {
local, cand int64
want float64
}{
{200000, 200000, 1.0}, // exact
{200000, 200500, 1.0}, // 0.5s — under 2s threshold
{200000, 201900, 1.0}, // 1.9s — under 2s threshold
{200000, 202000, 1.0}, // exactly 2s — still full credit
{0, 200000, 0.5}, // unknown local → neutral
{200000, 0, 0.5}, // unknown candidate → neutral
// Past 2s, score scales by delta / candidateMs. 20% of
// candidate length = fully wrong (0.0).
// 240s candidate, 12s delta = 5% → 1 - 5/20 = 0.75.
{240000 + 12000, 240000, 0.75},
// 240s candidate, 24s delta = 10% → 1 - 10/20 = 0.50.
{240000 + 24000, 240000, 0.50},
// 240s candidate, 48s delta = 20% → clamped to 0.
{240000 + 48000, 240000, 0.0},
// 240s candidate, 72s delta = 30% → still 0 (clamped).
{240000 + 72000, 240000, 0.0},
// Same absolute delta hits short tracks harder.
// 60s candidate, 6s delta = 10% → 0.50.
{66000, 60000, 0.50},
// 60s candidate, 12s delta = 20% → 0.0.
{72000, 60000, 0.0},
}
for _, tc := range cases {
got := lengthScore(tc.local, tc.cand)
if diff := got - tc.want; diff < -0.05 || diff > 0.05 { //nolint:mnd
t.Errorf(
"lengthScore(%d, %d) = %.3f, want %.3f ± 0.05",
tc.local, tc.cand, got, tc.want,
)
}
}
}
func TestTrackDistance(t *testing.T) {
t.Parallel()
local := LocalTrack{
Title: "Hey Jude",
TrackNumber: 1,
LengthMillis: 431000,
}
exact := CandidateTrack{
Position: 1,
Title: "Hey Jude",
LengthMillis: 431000,
}
if got := trackDistance(local, exact); got < 0.99 {
t.Errorf("exact match = %.2f, want ~1.0", got)
}
wrong := CandidateTrack{
Position: 5,
Title: "Yesterday",
LengthMillis: 125000,
}
if got := trackDistance(local, wrong); got > 0.20 {
t.Errorf("wrong match = %.2f, want < 0.2", got)
}
}
+47
View File
@@ -0,0 +1,47 @@
// 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. 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(discNumber)))
return hex.EncodeToString(h.Sum(nil))
}
+106
View File
@@ -0,0 +1,106 @@
package autotag_test
import (
"regexp"
"testing"
"yellowjacket/backend/autotag"
)
var hexSHA1 = regexp.MustCompile(`^[0-9a-f]{40}$`)
func TestGroupKey_FormatAndDeterminism(t *testing.T) {
t.Parallel()
key := autotag.GroupKey(1, "/music/Artist/Album/01.mp3", 1)
if !hexSHA1.MatchString(key) {
t.Fatalf("expected 40-char lowercase hex, got %q", key)
}
again := autotag.GroupKey(1, "/music/Artist/Album/01.mp3", 1)
if key != again {
t.Fatalf("GroupKey is not deterministic: %q vs %q", key, again)
}
}
func TestGroupKey_CaseInsensitiveParent(t *testing.T) {
t.Parallel()
a := autotag.GroupKey(1, "/Music/Artist/ALBUM/01.mp3", 1)
b := autotag.GroupKey(1, "/music/artist/album/01.mp3", 1)
if a != b {
t.Fatalf("parent dir case should not affect key: %q vs %q", a, b)
}
}
func TestGroupKey_AlbumTagDoesNotAffectKey(t *testing.T) {
t.Parallel()
// Folder = album. Tracks in the same folder MUST share a key
// regardless of any per-track variation in the album tag. This
// is the bug-fix this test guards against — earlier versions
// included the album tag in the hash, which fragmented albums
// whose tracks carried slightly different tags.
siblings := []string{
"/music/Artist/Album/01.mp3",
"/music/Artist/Album/02.mp3",
"/music/Artist/Album/03.mp3",
}
keys := make(map[string]struct{})
for _, p := range siblings {
keys[autotag.GroupKey(1, p, 0)] = struct{}{}
}
if len(keys) != 1 {
t.Fatalf(
"siblings in the same folder should share a key, got %d distinct: %v",
len(keys), keys,
)
}
}
func TestGroupKey_DistinctInputsDiffer(t *testing.T) {
t.Parallel()
base := autotag.GroupKey(1, "/music/Artist/Album/01.mp3", 1)
cases := map[string]string{
"different library": autotag.GroupKey(2, "/music/Artist/Album/01.mp3", 1),
"different parent": autotag.GroupKey(1, "/music/Artist/Other/01.mp3", 1),
"different disc": autotag.GroupKey(1, "/music/Artist/Album/01.mp3", 2),
"same sibling track": autotag.GroupKey(1, "/music/Artist/Album/02.mp3", 1),
}
for name, got := range cases {
if name == "same sibling track" {
if got != base {
t.Errorf("%s: expected same key as sibling track, got %q vs %q", name, got, base)
}
continue
}
if got == base {
t.Errorf("%s: expected distinct key, both %q", name, got)
}
}
}
func TestGroupKey_AmbiguityBoundary(t *testing.T) {
t.Parallel()
// Ensure the null-byte separator actually prevents "a|b" vs "ab|"
// ambiguity: if the hash naively concatenated with no separator,
// these two would collide.
a := autotag.GroupKey(1, "/a/b/01.mp3", 0)
b := autotag.GroupKey(1, "/a/bc/01.mp3", 0)
if a == b {
t.Fatalf(
"expected null-byte separator to disambiguate concatenations, both %q",
a,
)
}
}
+133
View File
@@ -0,0 +1,133 @@
package autotag
import (
"context"
"fmt"
"yellowjacket/backend/database/sql/sqlcgen"
)
// LocalResolver turns a tagging group's album name into zero-cost
// candidate releases by looking for local release_groups (with
// MBIDs) whose normalized name matches. The user's own tagged
// albums become free candidates — if they already have another
// library where the same album was tagged correctly, reuse that.
//
// The resolver talks to the DB through the sqlc-generated Queries
// type, not the database package's DB wrapper — keeps the import
// graph acyclic with the database package (which already depends
// on autotag.GroupKey for migration backfills).
type LocalResolver struct {
q *sqlcgen.Queries
}
// NewLocalResolver returns a resolver bound to the given Queries.
func NewLocalResolver(q *sqlcgen.Queries) *LocalResolver {
return &LocalResolver{q: q}
}
// LocalTracksForGroup returns the local audio files in the given
// tagging group, projected into the scorer-ready shape.
func (r *LocalResolver) LocalTracksForGroup(
ctx context.Context, groupKey string,
) ([]LocalTrack, error) {
rows, err := r.q.ListAudioFilesInTaggingGroup(ctx, groupKey)
if err != nil {
return nil, fmt.Errorf("list local tracks: %w", err)
}
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,
})
}
return out, nil
}
// ResolveLocal returns candidate releases sourced from the local
// DB's release_groups rows (filtered to those carrying an MBID)
// whose normalized name matches the tagging item's album name.
// No network calls. Candidates carry all tracks flat; caller runs
// AlignTracks on each to produce per-track alignments.
func (r *LocalResolver) ResolveLocal(
ctx context.Context, albumName string,
) ([]Candidate, error) {
if albumName == "" {
return nil, nil
}
rows, err := r.q.ListLocalReleaseGroupCandidates(ctx, albumName)
if err != nil {
return nil, fmt.Errorf("list local candidates: %w", err)
}
normalizedTarget := Normalize(albumName)
byID := make(map[int64]*Candidate, 4) //nolint:mnd
tracksByID := make(map[int64][]CandidateTrack, 4) //nolint:mnd
for _, row := range rows {
// Case-insensitive SQL match is a cheap pre-filter; we
// still apply our full normalization rule in Go to reject
// false positives like "Greatest Hits" vs "Greatest Hits".
if Normalize(row.AlbumName) != normalizedTarget {
continue
}
if _, ok := byID[row.ReleaseGroupID]; !ok {
byID[row.ReleaseGroupID] = localCandidate(row)
}
tracksByID[row.ReleaseGroupID] = append(
tracksByID[row.ReleaseGroupID],
CandidateTrack{
Position: int(row.TrackNumber),
DiscNumber: int(row.DiscNumber),
Title: row.TrackTitle,
LengthMillis: row.LengthMilliseconds,
MBID: row.RecordingMbid,
},
)
}
out := make([]Candidate, 0, len(byID))
for id, c := range byID {
c.Tracks = tracksByID[id]
c.TrackCount = len(c.Tracks)
out = append(out, *c)
}
return out, nil
}
// localCandidate converts one sqlc row (minus track-level fields)
// into a Candidate shell. Track fields and alignments are filled
// in by the caller.
func localCandidate(row sqlcgen.ListLocalReleaseGroupCandidatesRow) *Candidate {
date := ""
if row.Year > 0 {
date = fmt.Sprintf("%04d", row.Year)
}
mbid := ""
if row.ReleaseGroupMbid.Valid {
mbid = row.ReleaseGroupMbid.String
}
return &Candidate{
ReleaseGroupMBID: mbid,
Title: row.AlbumName,
ArtistCredit: row.ArtistCredit,
Date: date,
Source: SourceLocal,
Provenance: "local",
}
}
+346
View File
@@ -0,0 +1,346 @@
package autotag
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
)
// MBReleaseGroupHit is the minimal projection of a MusicBrainz
// release-group search result that the scorer consumes. Defined
// here (rather than imported from explore) to keep the autotag
// package's external surface small and swappable.
type MBReleaseGroupHit struct {
MBID string
Title string
ArtistCredit string
FirstDate string
PrimaryType string
}
// MBRelease is the scorer's projection of a MusicBrainz release
// (one specific edition with its track list).
type MBRelease struct {
MBID string
Title string
Date string
Country string
Status string
ArtistCredit string
Tracks []CandidateTrack
}
// MBClient is the subset of the explore.MusicBrainzClient surface
// the autotagger depends on. Implementations must be cache-first
// — repeated calls with the same inputs must not repeat network
// round-trips.
type MBClient interface {
SearchReleaseGroups(
ctx context.Context,
query string,
limit int,
) ([]MBReleaseGroupHit, int, error)
BrowseReleases(ctx context.Context, releaseGroupMBID string) ([]MBRelease, error)
LookupRelease(ctx context.Context, releaseMBID string) (MBRelease, error)
LookupReleaseGroup(ctx context.Context, releaseGroupMBID string) (MBReleaseGroupHit, error)
LookupArtist(ctx context.Context, mbid string) (string, error) // returns sort name or name
}
// MBResolver orchestrates MusicBrainz lookups for a tagging group.
// Strategy: normalize the user-provided album/artist first, then
// issue a cascade of progressively looser Lucene queries, stopping
// at the first one that yields enough candidates.
type MBResolver struct {
client MBClient
logger *slog.Logger
limit int
}
// NewMBResolver wires up the resolver with a default search limit.
func NewMBResolver(client MBClient, logger *slog.Logger) *MBResolver {
const defaultLimit = 5
return &MBResolver{client: client, logger: logger, limit: defaultLimit}
}
// mbQueryStep describes one cascade level. `label` is surfaced in
// candidate provenance (so the UI can show "via fuzzy title").
type mbQueryStep struct {
label string
query string
}
// ResolveMB returns MB-sourced candidates for a tagging group.
// Runs a cascade of Lucene queries, returning at the first step
// that produces results. Each search hit fans out to
// BrowseReleases (one per release-group) for track-level data.
func (r *MBResolver) ResolveMB(
ctx context.Context,
albumName, albumArtist string,
trackCount int,
knownArtistMBID string,
) ([]Candidate, error) {
if albumName == "" {
return nil, nil
}
nAlbum := Normalize(albumName)
nArtist := Normalize(albumArtist)
if nAlbum == "" {
return nil, nil
}
steps := buildMBQueryCascade(nAlbum, nArtist, trackCount, knownArtistMBID)
for _, step := range steps {
hits, _, err := r.client.SearchReleaseGroups(ctx, step.query, r.limit)
if err != nil {
r.logger.Warn(
"MB search step failed — trying next",
"step", step.label, "query", step.query, "err", err,
)
continue
}
if len(hits) == 0 {
r.logger.Debug(
"MB search step empty — trying next",
"step", step.label, "query", step.query,
)
continue
}
r.logger.Info(
"MB search step succeeded",
"step", step.label, "hits", len(hits),
)
return r.fanOutBrowse(ctx, hits, step.label), nil
}
return nil, nil
}
// fanOutBrowse iterates search hits, fetches each release-group's
// releases, and returns them as Candidates. Errors on individual
// browses are logged and skipped.
func (r *MBResolver) fanOutBrowse(
ctx context.Context, hits []MBReleaseGroupHit, step string,
) []Candidate {
var out []Candidate
for _, h := range hits {
releases, err := r.client.BrowseReleases(ctx, h.MBID)
if err != nil {
r.logger.Warn(
"browse releases failed — skipping release group",
"release_group_mbid", h.MBID, "err", err,
)
continue
}
for _, rel := range releases {
out = append(out, mkCandidate(h, rel, step))
}
}
return out
}
// ResolveOneReleaseMBID fetches a single release by MBID and
// returns it as a fully-populated Candidate. Used by the paste-
// URL escape hatch. Falls back to LookupReleaseGroup when the
// MBID resolves to a release group instead.
func (r *MBResolver) ResolveOneReleaseMBID(
ctx context.Context, mbid string,
) (Candidate, error) {
rel, err := r.client.LookupRelease(ctx, mbid)
if err == nil && rel.MBID != "" {
return Candidate{
ReleaseMBID: rel.MBID,
ReleaseGroupMBID: "",
Title: rel.Title,
ArtistCredit: rel.ArtistCredit,
Date: rel.Date,
Country: rel.Country,
Status: rel.Status,
TrackCount: len(rel.Tracks),
Tracks: rel.Tracks,
Source: SourceMusicBrainz,
Provenance: "paste",
}, nil
}
rgHit, rgErr := r.client.LookupReleaseGroup(ctx, mbid)
if rgErr != nil {
return Candidate{}, fmt.Errorf("lookup release or RG: %w / %w", err, rgErr)
}
releases, bErr := r.client.BrowseReleases(ctx, rgHit.MBID)
if bErr != nil {
return Candidate{}, fmt.Errorf("browse RG %s: %w", rgHit.MBID, bErr)
}
if len(releases) == 0 {
return Candidate{}, fmt.Errorf("%w: %s", errEmptyBrowseResult, mbid)
}
return mkCandidate(rgHit, releases[0], "paste"), nil
}
// mkCandidate combines a search hit with one of its releases into
// a Candidate ready for scoring. Date is the release-specific date
// (re-issue year for remasters), OriginalDate is the release-group's
// first-release-date (the album's original year).
func mkCandidate(h MBReleaseGroupHit, rel MBRelease, step string) Candidate {
return Candidate{
ReleaseMBID: rel.MBID,
ReleaseGroupMBID: h.MBID,
Title: firstNonEmpty(rel.Title, h.Title),
ArtistCredit: firstNonEmpty(rel.ArtistCredit, h.ArtistCredit),
Date: firstNonEmpty(rel.Date, h.FirstDate),
OriginalDate: h.FirstDate,
Country: rel.Country,
Status: rel.Status,
TrackCount: len(rel.Tracks),
Tracks: rel.Tracks,
Source: SourceMusicBrainz,
Provenance: step,
}
}
// buildMBQueryCascade returns the Lucene queries to try in order.
// Cascade:
//
// 1. Full: release + arid/artist + tracks:N
// 2. Drop tracks:N (bonus tracks, live editions, etc.)
// 3. Drop artist entirely (wrong artist tag is common)
// 4. Fuzzy title (unquoted; Lucene does token/prefix match)
//
// Each step is only added when it would differ from the previous.
func buildMBQueryCascade(
normAlbum, normArtist string,
trackCount int,
artistMBID string,
) []mbQueryStep {
var steps []mbQueryStep
release := "release:" + luceneQuote(normAlbum)
artistClause := ""
switch {
case artistMBID != "":
artistClause = "arid:" + artistMBID
case normArtist != "":
artistClause = "artist:" + luceneQuote(normArtist)
}
tracksClause := ""
if trackCount > 0 {
tracksClause = fmt.Sprintf("tracks:%d", trackCount)
}
// Step 1: full query (only include parts we actually have).
steps = append(steps, mbQueryStep{
label: "strict",
query: joinNonEmpty(release, artistClause, tracksClause),
})
// Step 2: drop tracks:N if we had one.
if tracksClause != "" {
steps = append(steps, mbQueryStep{
label: "no-track-count",
query: joinNonEmpty(release, artistClause),
})
}
// Step 3: drop the artist clause.
if artistClause != "" {
steps = append(steps, mbQueryStep{
label: "title-only",
query: release,
})
}
// Step 4: fuzzy / unquoted title. MB's Lucene tokenizer will
// do prefix + fuzzy matching on bare tokens.
fuzzy := "release:" + luceneTokens(normAlbum)
if fuzzy != release {
steps = append(steps, mbQueryStep{
label: "fuzzy-title",
query: fuzzy,
})
}
return steps
}
// luceneQuote wraps a phrase in quotes and escapes embedded quotes
// and backslashes. Used for exact-phrase clauses.
func luceneQuote(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
return `"` + s + `"`
}
// luceneTokens returns the phrase as space-separated tokens with
// Lucene reserved characters escaped. MB's analyzer applies
// tokenization + fuzzy matching across bare tokens, so this is
// the right shape for our loosest cascade level.
func luceneTokens(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return s
}
// Escape Lucene-reserved glyphs that aren't stripped by our
// Normalize() (paren, bracket, colon, etc. Normalize already
// drops most of these, but belt + braces).
reserved := `+-&|!(){}[]^"~*?:\/`
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if strings.ContainsRune(reserved, r) {
b.WriteRune('\\')
}
b.WriteRune(r)
}
return b.String()
}
// joinNonEmpty joins non-empty parts with " AND ".
func joinNonEmpty(parts ...string) string {
kept := make([]string, 0, len(parts))
for _, p := range parts {
if p != "" {
kept = append(kept, p)
}
}
return strings.Join(kept, " AND ")
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
// errEmptyBrowseResult signals that LookupReleaseGroup succeeded
// but BrowseReleases returned nothing — unusual, but not fatal.
var errEmptyBrowseResult = errors.New("autotag: release group has no releases")
+193
View File
@@ -0,0 +1,193 @@
package autotag
import (
"context"
"errors"
"log/slog"
"strings"
"testing"
)
var errFakeNotFound = errors.New("fake: not found")
// fakeMBClient is a minimal stub that records the queries the
// resolver sends and returns canned results per query/MBID. The
// cascade tests orchestrate `searchByStep` so step N returns hits
// only when the resolver has already tried steps < N.
type fakeMBClient struct {
queries []string
searchByStep map[int][]MBReleaseGroupHit
browseByMBID map[string][]MBRelease
lookupRels map[string]MBRelease
lookupRGs map[string]MBReleaseGroupHit
}
func (f *fakeMBClient) SearchReleaseGroups(
_ context.Context, query string, _ int,
) ([]MBReleaseGroupHit, int, error) {
f.queries = append(f.queries, query)
step := len(f.queries) - 1
hits := f.searchByStep[step]
return hits, len(hits), nil
}
func (f *fakeMBClient) BrowseReleases(
_ context.Context, mbid string,
) ([]MBRelease, error) {
return f.browseByMBID[mbid], nil
}
func (f *fakeMBClient) LookupRelease(
_ context.Context, mbid string,
) (MBRelease, error) {
rel, ok := f.lookupRels[mbid]
if !ok {
return MBRelease{}, errFakeNotFound
}
return rel, nil
}
func (f *fakeMBClient) LookupReleaseGroup(
_ context.Context, mbid string,
) (MBReleaseGroupHit, error) {
rg, ok := f.lookupRGs[mbid]
if !ok {
return MBReleaseGroupHit{}, errFakeNotFound
}
return rg, nil
}
func (f *fakeMBClient) LookupArtist(_ context.Context, _ string) (string, error) {
return "", nil
}
func TestBuildMBQueryCascade_StepsOrder(t *testing.T) {
t.Parallel()
// Normalize() is a no-op for these inputs — ASCII, no
// qualifier suffix, no punctuation — so cascade builds directly.
steps := buildMBQueryCascade("abbey road", "the beatles", 17, "")
if len(steps) < 3 {
t.Fatalf("expected ≥3 cascade steps, got %d", len(steps))
}
if !strings.Contains(steps[0].query, "tracks:17") {
t.Errorf("step 1 should include tracks:17, got %q", steps[0].query)
}
if strings.Contains(steps[1].query, "tracks:") {
t.Errorf("step 2 should drop tracks:, got %q", steps[1].query)
}
if strings.Contains(steps[2].query, "artist:") {
t.Errorf("step 3 should drop artist:, got %q", steps[2].query)
}
}
func TestBuildMBQueryCascade_NormalizesInputs(t *testing.T) {
t.Parallel()
// Qualifier suffix "(Remastered 2009)" must be stripped by
// the *caller*; verify the query emitter doesn't reintroduce it.
steps := buildMBQueryCascade("abbey road", "", 0, "")
for _, step := range steps {
if strings.Contains(strings.ToLower(step.query), "remastered") {
t.Errorf("step %q should not contain qualifier: %q", step.label, step.query)
}
}
}
func TestMBResolver_CascadeStopsOnFirstHit(t *testing.T) {
t.Parallel()
fake := &fakeMBClient{
searchByStep: map[int][]MBReleaseGroupHit{
// step 0 (strict) returns nothing; step 1 (no-track-count) hits.
1: {{MBID: "rg1", Title: "Abbey Road"}},
},
browseByMBID: map[string][]MBRelease{
"rg1": {{MBID: "rel1", Title: "Abbey Road", Tracks: []CandidateTrack{
{Position: 1, Title: "Come Together"},
}}},
},
}
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
cands, err := r.ResolveMB(
context.Background(), "Abbey Road", "The Beatles", 17, "",
)
if err != nil {
t.Fatalf("ResolveMB: %v", err)
}
if len(cands) != 1 {
t.Fatalf("expected 1 candidate, got %d", len(cands))
}
if len(fake.queries) != 2 { //nolint:mnd
t.Errorf("expected 2 search queries (strict + no-track-count), got %d", len(fake.queries))
}
if cands[0].Provenance != "no-track-count" {
t.Errorf("provenance = %q, want 'no-track-count'", cands[0].Provenance)
}
}
func TestMBResolver_AbortsOnEmptyAlbumName(t *testing.T) {
t.Parallel()
fake := &fakeMBClient{}
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
cands, err := r.ResolveMB(context.Background(), "", "", 0, "")
if err != nil {
t.Fatalf("ResolveMB: %v", err)
}
if cands != nil {
t.Errorf("cands should be nil for empty album, got %d", len(cands))
}
if len(fake.queries) != 0 {
t.Errorf("empty album should not trigger search, got %d queries", len(fake.queries))
}
}
func TestMBResolver_ResolveOneReleaseMBID(t *testing.T) {
t.Parallel()
fake := &fakeMBClient{
lookupRels: map[string]MBRelease{
"rel-mbid": {
MBID: "rel-mbid", Title: "Abbey Road",
ArtistCredit: "The Beatles",
Tracks: []CandidateTrack{{Position: 1, Title: "Come Together"}},
},
},
}
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
cand, err := r.ResolveOneReleaseMBID(context.Background(), "rel-mbid")
if err != nil {
t.Fatalf("ResolveOneReleaseMBID: %v", err)
}
if cand.Title != "Abbey Road" {
t.Errorf("title = %q, want Abbey Road", cand.Title)
}
if cand.Provenance != "paste" {
t.Errorf("provenance = %q, want 'paste'", cand.Provenance)
}
if len(cand.Tracks) != 1 {
t.Errorf("expected 1 track, got %d", len(cand.Tracks))
}
}
+77
View File
@@ -0,0 +1,77 @@
package autotag
import (
"regexp"
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
// qualifierPattern matches common parenthesized / bracketed
// qualifiers that MB sometimes adds to titles but user tags often
// omit — e.g. `(Remastered 2009)`, `[Bonus Track]`, `(feat. X)`.
// Case-insensitive. Only strips when the qualifier is at a
// word-boundary to avoid mangling titles like "Untitled (1)".
var qualifierPattern = regexp.MustCompile(
`(?i)\s*[\(\[]\s*(` +
`remaster(ed)?(\s+\d{4})?|` +
`re-?master(ed)?(\s+\d{4})?|` +
`\d{4}\s+remaster(ed)?|` +
`deluxe(\s+edition)?|` +
`expanded(\s+edition)?|` +
`explicit|` +
`clean|` +
`bonus\s+track|` +
`live(\s+at\s+[^\)\]]*)?|` +
`acoustic|` +
`radio\s+edit|` +
`single\s+version|` +
`album\s+version|` +
`instrumental|` +
`demo|` +
`mono|` +
`stereo|` +
`feat\.?\s+[^\)\]]*|` +
`featuring\s+[^\)\]]*|` +
`ft\.?\s+[^\)\]]*` +
`)\s*[\)\]]`,
)
// whitespaceCollapse replaces runs of whitespace with a single space.
var whitespaceCollapse = regexp.MustCompile(`\s+`)
// Normalize returns a comparison-friendly form of a title or
// artist-credit string:
//
// - NFC unicode composition
// - qualifier suffixes stripped (see qualifierPattern)
// - all punctuation dropped
// - case folded (lowercased)
// - whitespace collapsed and trimmed
//
// The result is not intended to be human-readable — only used for
// equality and edit-distance comparisons inside the scorer.
func Normalize(s string) string {
if s == "" {
return ""
}
s = norm.NFC.String(s)
s = qualifierPattern.ReplaceAllString(s, "")
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
b.WriteRune(unicode.ToLower(r))
case unicode.IsSpace(r):
b.WriteRune(' ')
}
}
return strings.TrimSpace(whitespaceCollapse.ReplaceAllString(b.String(), " "))
}
+45
View File
@@ -0,0 +1,45 @@
package autotag_test
import (
"testing"
"yellowjacket/backend/autotag"
)
func TestNormalize(t *testing.T) {
t.Parallel()
cases := map[string]struct {
in string
want string
}{
"empty": {"", ""},
"already normalized": {"abbey road", "abbey road"},
"case fold": {"Abbey Road", "abbey road"},
"punctuation stripped": {
"Sgt. Pepper's Lonely Hearts Club Band!",
"sgt peppers lonely hearts club band",
},
"NFC unicode": {"Beyoncé", "beyoncé"},
"remastered qualifier": {"Abbey Road (Remastered 2009)", "abbey road"},
"remaster no year": {"Abbey Road (Remaster)", "abbey road"},
"explicit qualifier": {"Lemonade [Explicit]", "lemonade"},
"feat qualifier": {"Yellow (feat. Coldplay)", "yellow"},
"bonus track qualifier": {"Hey Jude [Bonus Track]", "hey jude"},
"collapse whitespace": {" Abbey Road ", "abbey road"},
"non-ascii digits kept": {"Track 7", "track 7"},
"numbered title not mangled": {"Untitled (1)", "untitled 1"},
"year remaster form": {"Abbey Road (2009 Remaster)", "abbey road"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
got := autotag.Normalize(tc.in)
if got != tc.want {
t.Errorf("Normalize(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
+185
View File
@@ -0,0 +1,185 @@
package autotag
import (
"sort"
"strconv"
"strings"
)
// Release-level scoring weights. Aggregate track score is the
// dominant signal — the release-level signals are tie-breakers
// when the track alignment is roughly comparable.
const (
weightTrackAggregate = 0.70
weightTrackCountMatch = 0.15
weightReleaseMeta = 0.15 // Official + country averaged
// Country preference: a very mild nudge toward releases from
// the user's locale. Will become a config option in 012.
preferredCountry = "US"
)
// ScoreCandidate fills in c.Alignments, c.Score, c.Breakdown, and
// c.TrackCount for a single candidate against the given local
// tracks. The returned Candidate is safe to copy — no shared
// state with the caller's slice.
func ScoreCandidate(local []LocalTrack, c Candidate, localTrackCount int) Candidate {
c.Alignments = AlignTracks(local, c.Tracks)
var (
titleSum float64
lengthSum float64
counted int
)
for _, a := range c.Alignments {
if a.Status != AlignmentMatched && a.Status != AlignmentMismatched {
continue
}
counted++
titleSum += a.TitleScore
l := local[a.LocalIndex]
lengthSum += lengthScore(l.LengthMillis, a.CandidateLength)
}
titleAvg, lengthAvg := 0.0, 0.0
if counted > 0 {
titleAvg = titleSum / float64(counted)
lengthAvg = lengthSum / float64(counted)
}
// Aggregate track score: weighted title + length (renormalized
// so a perfect match scales to 1.0 regardless of the absolute
// weights), scaled by how many of our local tracks actually
// matched — extra or missing tracks punish proportionally.
coverage := 0.0
if len(local) > 0 {
coverage = float64(counted) / float64(len(local))
}
const trackWeightSum = weightTitle + weightLength
trackAgg := ((titleAvg*weightTitle + lengthAvg*weightLength) / trackWeightSum) * coverage
trackCountScore := trackCountMatch(len(c.Tracks), localTrackCount)
// Release-meta is just official-status + country preference,
// averaged. We used to mix in a year bonus too, but that
// compared candidate years against time.Now() — penalising
// every album that wasn't from this year, regardless of how
// well it matched the local files. See git history.
const metaTerms = 2.0
meta := (officialBonus(c.Status) + countryBonus(c.Country)) / metaTerms
c.Score = trackAgg*weightTrackAggregate +
trackCountScore*weightTrackCountMatch +
meta*weightReleaseMeta
c.Breakdown = ScoreBreakdown{
TitleAvg: titleAvg,
LengthAvg: lengthAvg,
TrackCountFit: trackCountScore,
ReleaseMeta: meta,
}
c.TrackCount = len(c.Tracks)
return c
}
// trackCountMatch returns 1.0 when equal, 0.0 when off by >= 50%,
// linear between.
func trackCountMatch(a, b int) float64 {
if a == 0 && b == 0 {
return 1.0
}
if a == 0 || b == 0 {
return 0.0
}
diff := a - b
if diff < 0 {
diff = -diff
}
larger := a
if b > larger {
larger = b
}
frac := float64(diff) / float64(larger)
const halfwayPenalty = 0.5
if frac >= halfwayPenalty {
return 0.0
}
return 1.0 - frac/halfwayPenalty
}
// officialBonus returns 1.0 for Official releases, 0.5 for others
// (Promotion, Bootleg, ...), 0.5 when unknown.
func officialBonus(status string) float64 {
const partial = 0.5
switch strings.ToLower(status) {
case "official":
return 1.0
case "":
return partial
default:
return partial
}
}
// countryBonus gives a mild nudge toward releases from the
// preferred country. Neutral (0.5) when country is absent.
func countryBonus(country string) float64 {
const (
neutral = 0.5
hit = 1.0
)
if country == "" {
return neutral
}
if strings.EqualFold(country, preferredCountry) {
return hit
}
return neutral
}
// parseYear pulls the first 4-digit year out of date strings like
// "2009", "2009-05-18", "".
func parseYear(date string) int {
if len(date) < 4 { //nolint:mnd
return 0
}
y, err := strconv.Atoi(date[:4])
if err != nil {
return 0
}
return y
}
// RankCandidates scores each candidate against the local tracks
// and returns a new slice sorted descending by score. Input slice
// is not modified.
func RankCandidates(local []LocalTrack, candidates []Candidate) []Candidate {
scored := make([]Candidate, 0, len(candidates))
for _, c := range candidates {
scored = append(scored, ScoreCandidate(local, c, len(local)))
}
sort.SliceStable(scored, func(i, j int) bool {
return scored[i].Score > scored[j].Score
})
return scored
}
+174
View File
@@ -0,0 +1,174 @@
package autotag_test
import (
"testing"
"yellowjacket/backend/autotag"
)
func TestRankCandidates_PrefersExactTrackCountMatch(t *testing.T) {
t.Parallel()
local := []autotag.LocalTrack{
{Title: "A", TrackNumber: 1, LengthMillis: 200000},
{Title: "B", TrackNumber: 2, LengthMillis: 200000},
{Title: "C", TrackNumber: 3, LengthMillis: 200000},
}
matching := autotag.Candidate{
ReleaseMBID: "exact",
Title: "Album",
Tracks: []autotag.CandidateTrack{
{Position: 1, Title: "A", LengthMillis: 200000},
{Position: 2, Title: "B", LengthMillis: 200000},
{Position: 3, Title: "C", LengthMillis: 200000},
},
}
longer := autotag.Candidate{
ReleaseMBID: "longer",
Title: "Album",
Tracks: []autotag.CandidateTrack{
{Position: 1, Title: "A", LengthMillis: 200000},
{Position: 2, Title: "B", LengthMillis: 200000},
{Position: 3, Title: "C", LengthMillis: 200000},
{Position: 4, Title: "Bonus", LengthMillis: 200000},
{Position: 5, Title: "Extra", LengthMillis: 200000},
},
}
ranked := autotag.RankCandidates(local, []autotag.Candidate{longer, matching})
if ranked[0].ReleaseMBID != "exact" {
t.Errorf(
"top = %q (score %.2f vs %.2f), want 'exact'",
ranked[0].ReleaseMBID, ranked[0].Score, ranked[1].Score,
)
}
}
func TestRankCandidates_PrefersOfficial(t *testing.T) {
t.Parallel()
local := []autotag.LocalTrack{
{Title: "A", TrackNumber: 1, LengthMillis: 200000},
}
tracks := []autotag.CandidateTrack{
{Position: 1, Title: "A", LengthMillis: 200000},
}
official := autotag.Candidate{
ReleaseMBID: "official",
Status: "Official",
Tracks: tracks,
}
promo := autotag.Candidate{
ReleaseMBID: "promo",
Status: "Promotion",
Tracks: tracks,
}
ranked := autotag.RankCandidates(local, []autotag.Candidate{promo, official})
if ranked[0].ReleaseMBID != "official" {
t.Errorf("top = %q, want 'official'", ranked[0].ReleaseMBID)
}
}
func TestRankCandidates_MultiDisc(t *testing.T) {
t.Parallel()
// Two-disc album: local has 4 tracks across 2 discs, candidate
// matches them. Should score near-perfectly.
local := []autotag.LocalTrack{
{Title: "D1T1", DiscNumber: 1, TrackNumber: 1, LengthMillis: 200000},
{Title: "D1T2", DiscNumber: 1, TrackNumber: 2, LengthMillis: 200000},
{Title: "D2T1", DiscNumber: 2, TrackNumber: 1, LengthMillis: 200000},
{Title: "D2T2", DiscNumber: 2, TrackNumber: 2, LengthMillis: 200000},
}
cand := autotag.Candidate{
ReleaseMBID: "multidisc",
Status: "Official",
Tracks: []autotag.CandidateTrack{
{Position: 1, DiscNumber: 1, Title: "D1T1", LengthMillis: 200000},
{Position: 2, DiscNumber: 1, Title: "D1T2", LengthMillis: 200000},
{Position: 1, DiscNumber: 2, Title: "D2T1", LengthMillis: 200000},
{Position: 2, DiscNumber: 2, Title: "D2T2", LengthMillis: 200000},
},
}
ranked := autotag.RankCandidates(local, []autotag.Candidate{cand})
if ranked[0].Score < 0.75 { //nolint:mnd
t.Errorf("multi-disc exact match = %.2f, want >= 0.75", ranked[0].Score)
}
}
func TestRankCandidates_VariousArtists(t *testing.T) {
t.Parallel()
// Compilation: every track has a different artist in the tag.
// Scoring should still produce a high score on title + length
// even though artist credits differ.
local := []autotag.LocalTrack{
{Title: "Song 1", Artist: "Artist One", TrackNumber: 1, LengthMillis: 200000},
{Title: "Song 2", Artist: "Artist Two", TrackNumber: 2, LengthMillis: 180000},
{Title: "Song 3", Artist: "Artist Three", TrackNumber: 3, LengthMillis: 220000},
}
cand := autotag.Candidate{
ReleaseMBID: "va-comp",
Title: "Various: Great Hits",
Status: "Official",
Tracks: []autotag.CandidateTrack{
{Position: 1, Title: "Song 1", LengthMillis: 200000},
{Position: 2, Title: "Song 2", LengthMillis: 180000},
{Position: 3, Title: "Song 3", LengthMillis: 220000},
},
}
ranked := autotag.RankCandidates(local, []autotag.Candidate{cand})
if ranked[0].Score < 0.75 { //nolint:mnd
t.Errorf("VA-compilation exact track match = %.2f, want >= 0.75", ranked[0].Score)
}
}
func TestRankCandidates_AmbiguousAlbumNames(t *testing.T) {
t.Parallel()
// Two candidates with the same album title ("Greatest Hits")
// but one has matching tracks and one doesn't. The track
// alignment should pick the right one.
local := []autotag.LocalTrack{
{Title: "Hotel California", TrackNumber: 1, LengthMillis: 391000},
{Title: "Take It Easy", TrackNumber: 2, LengthMillis: 213000},
}
eagles := autotag.Candidate{
ReleaseMBID: "eagles-gh",
Title: "Greatest Hits",
ArtistCredit: "Eagles",
Tracks: []autotag.CandidateTrack{
{Position: 1, Title: "Hotel California", LengthMillis: 391000},
{Position: 2, Title: "Take It Easy", LengthMillis: 213000},
},
}
queen := autotag.Candidate{
ReleaseMBID: "queen-gh",
Title: "Greatest Hits",
ArtistCredit: "Queen",
Tracks: []autotag.CandidateTrack{
{Position: 1, Title: "Bohemian Rhapsody", LengthMillis: 354000},
{Position: 2, Title: "We Will Rock You", LengthMillis: 121000},
},
}
ranked := autotag.RankCandidates(local, []autotag.Candidate{queen, eagles})
if ranked[0].ReleaseMBID != "eagles-gh" {
t.Errorf(
"top = %q (%.2f vs %.2f), want 'eagles-gh'",
ranked[0].ReleaseMBID, ranked[0].Score, ranked[1].Score,
)
}
}
+174
View File
@@ -0,0 +1,174 @@
package autotag
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"yellowjacket/backend/database/sql/sqlcgen"
)
// ErrGroupNotFound is returned when ScoreGroup is asked to score
// a group_key that isn't in tagging_items.
var ErrGroupNotFound = errors.New("autotag: tagging group not found")
// Scorer ties the local resolver and the optional MB resolver
// together, producing ranked candidates for a tagging group and
// optionally persisting the top pick back to tagging_items.
type Scorer struct {
q *sqlcgen.Queries
local *LocalResolver
mb *MBResolver // may be nil for dry-run / offline scoring
log *slog.Logger
}
// NewScorer wires up a Scorer. Pass mb=nil to disable MB calls
// entirely (useful for tuning / tests).
func NewScorer(q *sqlcgen.Queries, mb MBClient, logger *slog.Logger) *Scorer {
var resolver *MBResolver
if mb != nil {
resolver = NewMBResolver(mb, logger)
}
return &Scorer{
q: q,
local: NewLocalResolver(q),
mb: resolver,
log: logger,
}
}
// ScoreGroup produces the full GroupScore for one tagging item.
// Always runs both the local resolver (free) and the MB resolver
// (cache-first, so repeats cost nothing). Candidates from both
// sources are merged and ranked — the UI displays them with
// provenance badges so the user can compare.
func (s *Scorer) ScoreGroup(
ctx context.Context, groupKey string,
) (*GroupScore, error) {
item, err := s.q.GetTaggingItem(ctx, groupKey)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrGroupNotFound
}
return nil, fmt.Errorf("get tagging item: %w", err)
}
locals, err := s.local.LocalTracksForGroup(ctx, groupKey)
if err != nil {
return nil, err
}
localHits, err := s.local.ResolveLocal(ctx, item.AlbumName)
if err != nil {
return nil, err
}
var mbHits []Candidate
if s.mb != nil {
mbHits, err = s.mb.ResolveMB(
ctx,
item.AlbumName,
item.AlbumArtist,
len(locals),
guessArtistMBID(locals),
)
if err != nil {
s.log.Warn(
"MB resolve failed — returning local-only candidates",
"group_key", groupKey,
"err", err,
)
}
}
candidates := RankCandidates(locals, append(localHits, mbHits...))
return &GroupScore{
GroupKey: groupKey,
LocalTracks: locals,
Candidates: candidates,
}, nil
}
// LocalTracksForGroup exposes the local resolver so callers that
// already have a candidate list (e.g. from the service-layer
// candidate cache) can build a GroupScore without re-running the
// whole scorer.
func (s *Scorer) LocalTracksForGroup(
ctx context.Context, groupKey string,
) ([]LocalTrack, error) {
return s.local.LocalTracksForGroup(ctx, groupKey)
}
// PersistBest writes the top candidate's release MBID and score
// onto the tagging_items row, bumping status to 'matched' when a
// candidate exists. No-op when candidates is empty (keeps the
// current status — likely 'pending').
func (s *Scorer) PersistBest(
ctx context.Context, score *GroupScore,
) error {
if score == nil || len(score.Candidates) == 0 {
return nil
}
top := score.Candidates[0]
mbid := top.ReleaseMBID
if mbid == "" {
mbid = top.ReleaseGroupMBID
}
return s.q.SetTaggingItemBestMatch(ctx, sqlcgen.SetTaggingItemBestMatchParams{
BestMatchReleaseMbid: sql.NullString{String: mbid, Valid: mbid != ""},
Score: sql.NullFloat64{Float64: top.Score, Valid: true},
Status: "matched",
GroupKey: score.GroupKey,
})
}
// PersistScore writes the top candidate's release MBID and score
// onto the tagging_items row WITHOUT touching its status. Use
// this from paths that want the sidebar pill / sort to reflect a
// fresh score but must leave 'pending' folders pending — namely
// the live re-score on folder open and the background prefetch
// worker. No-op when candidates is empty.
func (s *Scorer) PersistScore(
ctx context.Context, score *GroupScore,
) error {
if score == nil || len(score.Candidates) == 0 {
return nil
}
top := score.Candidates[0]
mbid := top.ReleaseMBID
if mbid == "" {
mbid = top.ReleaseGroupMBID
}
return s.q.SetTaggingItemScore(ctx, sqlcgen.SetTaggingItemScoreParams{
BestMatchReleaseMbid: sql.NullString{String: mbid, Valid: mbid != ""},
Score: sql.NullFloat64{Float64: top.Score, Valid: true},
GroupKey: score.GroupKey,
})
}
// guessArtistMBID returns the first non-empty recording MBID-
// derived artist hint we can find. Tracks carry recording MBIDs,
// not artist MBIDs, but existing partial tags are often consistent
// enough that any non-empty MBID signals "this album already has
// some MB lineage". A follow-up in 010 can resolve actual artist
// MBIDs via the artists table.
func guessArtistMBID(tracks []LocalTrack) string {
// Phase 009 doesn't wire up per-track artist MBIDs through
// the LocalTrack struct yet — keeping the hook so the MB
// resolver still compiles with the empty hint. Real artist
// MBIDs flow in once 010 wires them into LocalTrack.
_ = tracks
return ""
}
+280
View File
@@ -0,0 +1,280 @@
package autotag_test
import (
"context"
"database/sql"
"log/slog"
"testing"
"yellowjacket/backend/autotag"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
)
// seedAlbum drops a minimal release_group + recordings + audio_files
// chain into the test DB, returning the group_key derived by the
// scan pipeline.
type seededAlbum struct {
groupKey string
albumName string
libraryID int64
releaseMBID string // MBID set on release_groups row (empty → none)
tracks []seededTrack
}
type seededTrack struct {
filePath string
title string
trackNumber int
lengthMillis int64
recordingMBID string // set a recording MBID to mark this track as "already tagged"
}
func seed(t *testing.T, db *database.DB, album seededAlbum) {
t.Helper()
ctx := db.Ctx
q := db.Queries
ac, err := q.UpsertArtistCredit(ctx, "Test Artist")
if err != nil {
t.Fatalf("upsert ac: %v", err)
}
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
Name: album.albumName,
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
})
if err != nil {
t.Fatalf("upsert rg: %v", err)
}
if album.releaseMBID != "" {
if _, err := db.ExecContext(
`UPDATE release_groups SET mbid = ? WHERE id = ?`,
album.releaseMBID, rg.ID,
); err != nil {
t.Fatalf("set rg mbid: %v", err)
}
}
for _, tr := range album.tracks {
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
Name: tr.title,
ArtistCreditID: ac.ID,
TrackNumber: sql.NullInt64{Int64: int64(tr.trackNumber), Valid: true},
})
if err != nil {
t.Fatalf("create recording: %v", err)
}
if tr.recordingMBID != "" {
if _, err := db.ExecContext(
`UPDATE recordings SET mbid = ? WHERE id = ?`,
tr.recordingMBID, rec.ID,
); err != nil {
t.Fatalf("set recording mbid: %v", err)
}
}
if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{
ReleaseGroupID: rg.ID,
RecordingID: rec.ID,
TrackNumber: sql.NullInt64{Int64: int64(tr.trackNumber), Valid: true},
}); err != nil {
t.Fatalf("link rg recording: %v", err)
}
if _, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
FilePath: tr.filePath,
LengthMilliseconds: tr.lengthMillis,
FileTypeID: 0,
RecordingID: rec.ID,
Basename: tr.filePath,
LibraryID: album.libraryID,
GroupKey: album.groupKey,
TagStatus: "untagged",
}); err != nil {
t.Fatalf("create audio file: %v", err)
}
}
if _, err := db.ExecContext(`
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
VALUES (?, ?, ?, ?, ?, 0, 'pending')
`, album.groupKey, album.libraryID, len(album.tracks), album.albumName, "Test Artist"); err != nil {
t.Fatalf("insert tagging item: %v", err)
}
}
func TestScorer_LocalHitSurfacesFirst(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
// Seed a local release_group WITH an MBID — this is the
// zero-cost candidate. Canonical tracks carry recording MBIDs
// so the local resolver treats them as tagged candidates.
seed(t, db, seededAlbum{
groupKey: "g-canonical",
albumName: "Good Album",
libraryID: 0,
releaseMBID: "rg-abcd",
tracks: []seededTrack{
{
filePath: "/lib/a.mp3", title: "Song A",
trackNumber: 1, lengthMillis: 200000,
recordingMBID: "rec-a",
},
{
filePath: "/lib/b.mp3", title: "Song B",
trackNumber: 2, lengthMillis: 180000,
recordingMBID: "rec-b",
},
},
})
// Seed a pending group whose album name matches the canonical.
// Local resolver finds canonical as a candidate; MB would also
// run in parallel (we no longer short-circuit it). The fake
// returns no MB hits here so local is the only ranked option.
seed(t, db, seededAlbum{
groupKey: "g-pending",
albumName: "Good Album",
libraryID: 0,
tracks: []seededTrack{
{filePath: "/other/a.mp3", title: "Song A", trackNumber: 1, lengthMillis: 200000},
{filePath: "/other/b.mp3", title: "Song B", trackNumber: 2, lengthMillis: 180000},
},
})
fakeMB := &countingMBClient{onSearch: func() {}}
scorer := autotag.NewScorer(db.Queries, fakeMB, slog.New(slog.DiscardHandler))
result, err := scorer.ScoreGroup(context.Background(), "g-pending")
if err != nil {
t.Fatalf("ScoreGroup: %v", err)
}
if len(result.Candidates) == 0 {
t.Fatal("no candidates")
}
if result.Candidates[0].ReleaseGroupMBID != "rg-abcd" {
t.Errorf("top candidate mbid = %q, want 'rg-abcd'", result.Candidates[0].ReleaseGroupMBID)
}
}
func TestScorer_PersistBestWritesMatched(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seed(t, db, seededAlbum{
groupKey: "g-canonical",
albumName: "Good Album",
releaseMBID: "rg-abcd",
tracks: []seededTrack{
{
filePath: "/lib/a.mp3", title: "Song A",
trackNumber: 1, lengthMillis: 200000,
recordingMBID: "rec-a",
},
},
})
seed(t, db, seededAlbum{
groupKey: "g-pending",
albumName: "Good Album",
tracks: []seededTrack{
{filePath: "/other/a.mp3", title: "Song A", trackNumber: 1, lengthMillis: 200000},
},
})
scorer := autotag.NewScorer(
db.Queries, nil, slog.New(slog.DiscardHandler),
)
result, err := scorer.ScoreGroup(context.Background(), "g-pending")
if err != nil {
t.Fatalf("ScoreGroup: %v", err)
}
if err := scorer.PersistBest(context.Background(), result); err != nil {
t.Fatalf("PersistBest: %v", err)
}
got, err := db.Queries.GetTaggingItem(context.Background(), "g-pending")
if err != nil {
t.Fatalf("reload: %v", err)
}
if got.Status != "matched" {
t.Errorf("status = %q, want 'matched'", got.Status)
}
if !got.BestMatchReleaseMbid.Valid || got.BestMatchReleaseMbid.String != "rg-abcd" {
t.Errorf("best_match_release_mbid = %+v, want rg-abcd", got.BestMatchReleaseMbid)
}
if !got.Score.Valid || got.Score.Float64 < 0.9 { //nolint:mnd
t.Errorf("score = %+v, want >= 0.9", got.Score)
}
}
func TestScorer_GroupNotFound(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
scorer := autotag.NewScorer(db.Queries, nil, slog.New(slog.DiscardHandler))
_, err := scorer.ScoreGroup(context.Background(), "nonexistent")
if err == nil || err.Error() != autotag.ErrGroupNotFound.Error() {
t.Fatalf("err = %v, want ErrGroupNotFound", err)
}
}
// countingMBClient is a fake that increments a counter whenever
// the scorer would have made an MB call. Used to assert the
// zero-network-call path stays zero.
type countingMBClient struct {
onSearch func()
}
func (c *countingMBClient) SearchReleaseGroups(
_ context.Context, _ string, _ int,
) ([]autotag.MBReleaseGroupHit, int, error) {
c.onSearch()
return nil, 0, nil
}
func (c *countingMBClient) BrowseReleases(
_ context.Context, _ string,
) ([]autotag.MBRelease, error) {
c.onSearch()
return nil, nil
}
func (c *countingMBClient) LookupArtist(_ context.Context, _ string) (string, error) {
c.onSearch()
return "", nil
}
func (c *countingMBClient) LookupRelease(_ context.Context, _ string) (autotag.MBRelease, error) {
c.onSearch()
return autotag.MBRelease{}, nil
}
func (c *countingMBClient) LookupReleaseGroup(
_ context.Context,
_ string,
) (autotag.MBReleaseGroupHit, error) {
c.onSearch()
return autotag.MBReleaseGroupHit{}, nil
}
+110
View File
@@ -0,0 +1,110 @@
package autotag
// LocalTrack is an audio_files row projected into the shape the
// scorer cares about. All durations are in milliseconds to match
// what the MB client returns.
type LocalTrack struct {
AudioFileID int64
FilePath string
Title string
Artist string
TrackNumber int
DiscNumber int
LengthMillis int64
RecordingMBID string
}
// CandidateSource distinguishes candidates served from the local
// release_groups cache (zero network cost) from those fetched live.
type CandidateSource string
// CandidateSource values.
const (
SourceLocal CandidateSource = "local"
SourceMusicBrainz CandidateSource = "musicbrainz"
)
// Candidate is a single release that could match an album-group.
// Track alignments are produced by the scorer and carry the per-
// field diff data the review UI renders.
//
// Date is this *specific release's* date (e.g. 2010 for a remaster);
// OriginalDate is the *release group's* first-release-date (the
// original album year, e.g. 1973), populated from MB's
// release-group.first-release-date and equal to Date when the
// release-group has only one release.
type Candidate struct {
ReleaseMBID string
ReleaseGroupMBID string
Title string
ArtistCredit string
Date string // "YYYY" or "YYYY-MM-DD" — this release
OriginalDate string // "YYYY" or "YYYY-MM-DD" — release group's first release
Country string
Status string // "Official", "Promotion", ...
TrackCount int
Tracks []CandidateTrack
Alignments []TrackAlignment
Score float64 // 0..1, higher is better
Breakdown ScoreBreakdown
Source CandidateSource
Provenance string // cascade step that produced this ("strict", "fuzzy-title", "paste", "local")
}
// ScoreBreakdown exposes the four inputs that go into Candidate.Score
// so the review UI can explain the ranking to the user.
type ScoreBreakdown struct {
TitleAvg float64 // average per-track title similarity (0..1)
LengthAvg float64 // average per-track length similarity (0..1)
TrackCountFit float64 // 1.0 when local and candidate track counts match
ReleaseMeta float64 // year + official + country, averaged
}
// CandidateTrack is one track inside a candidate release.
type CandidateTrack struct {
Position int
DiscNumber int
Title string
LengthMillis int64
MBID string
}
// AlignmentStatus classifies what happened when aligning one local
// track to the best-matching candidate track.
type AlignmentStatus string
// AlignmentStatus values.
const (
AlignmentMatched AlignmentStatus = "matched"
AlignmentMissing AlignmentStatus = "missing" // candidate has the track, folder doesn't
AlignmentUnmatched AlignmentStatus = "unmatched" // folder has the track, candidate doesn't
AlignmentMismatched AlignmentStatus = "mismatched" // aligned but low confidence
)
// TrackAlignment carries the per-track diff data for one local-
// track ↔ candidate-track pairing. LocalIndex is -1 when the
// candidate track has no local file (missing); CandidatePosition
// is 0 when the local track has no match (unmatched).
type TrackAlignment struct {
LocalIndex int // index into LocalTracks, or -1
LocalTitle string
LocalLengthMillis int64
CandidatePosition int
CandidateDiscNumber int
CandidateTitle string
CandidateMBID string
CandidateLength int64
TitleScore float64 // 0..1 from normalized-title edit distance
LengthDeltaMs int64 // abs(local - candidate); 0 if either side missing
TrackNumberOK bool // local track number matches candidate position
Status AlignmentStatus
}
// GroupScore is the scorer's full output for one tagging group.
type GroupScore struct {
GroupKey string
LocalTracks []LocalTrack
Candidates []Candidate // sorted by Score, descending
}