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