wip on autotagging

This commit is contained in:
2026-05-01 11:52:50 -04:00
parent 5cf019a0ac
commit d5140395da
295 changed files with 11105 additions and 40714 deletions
+26
View File
@@ -14,6 +14,7 @@ import (
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/assets"
"yellowjacket/backend/autotagservice"
"yellowjacket/backend/config"
"yellowjacket/backend/coverart"
"yellowjacket/backend/database"
@@ -42,6 +43,7 @@ type YellowJacketApp struct {
playlist *playlist.Service
queue *queue.Queue
explore *explore.Service
autotag *autotagservice.Service
mediaControls mediacontrols.Handler
tagWriter *tagwriter.TagWriter
appContext context.Context
@@ -146,6 +148,14 @@ func NewYellowJacketApp(
yjApp.logger.WithGroup("explore"), yjApp.database,
)
// create autotag service (depends on explore + tagWriter)
yjApp.autotag = autotagservice.NewService(
yjApp.logger.WithGroup("autotag"),
yjApp.database,
yjApp.explore,
yjApp.tagWriter,
)
yjApp.FEBindings = []any{
yjApp.FrontendUtil,
yjApp.appConfig,
@@ -155,6 +165,7 @@ func NewYellowJacketApp(
yjApp.player,
yjApp.tagWriter,
yjApp.explore,
yjApp.autotag,
}
return yjApp, nil
@@ -204,6 +215,7 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
yj.player.SetContext(ctx)
yj.tagWriter.SetContext(ctx)
yj.explore.SetContext(ctx)
yj.autotag.SetContext(ctx)
// Wire queue (created in NewYellowJacketApp for Wails binding)
yj.queue.SetContext(ctx)
@@ -248,6 +260,13 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
// sitewide + similar artist tiers run even if the index
// already has library data.
yj.explore.StartIndexBuild()
// Sweep the autotag queue for newly-discovered pending
// items so the user sees match scores ready when they
// next open the review page. The worker is idempotent
// (skips items that already have a score) and yields
// to foreground review activity.
yj.autotag.StartBackgroundPrefetch()
},
})
@@ -376,5 +395,12 @@ func (yj *YellowJacketApp) OnDomReady(ctx context.Context) {
if yj.library.GetScanQueueLength() == 0 && !yj.library.IsScanActive() {
yj.explore.StartIndexBuild()
}
// Kick off the autotag prefetch worker so any unscored
// pending items get their match scores filled in while
// the user does other things. Idempotent — re-running on
// every app launch is fine; previously-scored items are
// skipped (the worker filters score IS NULL).
yj.autotag.StartBackgroundPrefetch()
}()
}
+153
View File
@@ -0,0 +1,153 @@
package autotag
// alignTitleFloor is the minimum title similarity required to
// pair a local file with a candidate track at all. Below this,
// even the best available pair is treated as no pair: the local
// stays "unmatched" and the candidate slot stays "missing". Set
// well below titleReject so a local file with the wrong track
// number but mostly-correct title still pairs (and surfaces the
// number mismatch as a diff), while a totally unrelated file that
// happens to share a track number stays in its own group.
const alignTitleFloor = 0.30
// AlignTracks pairs local tracks with candidate tracks using a
// greedy best-score algorithm: repeatedly pick the (local, cand)
// pair with the highest trackDistance that hasn't already been
// claimed. Not optimal (Hungarian would be), but good enough for
// the small cardinalities we see (album tracks, ~10-50) and much
// simpler.
//
// Pairs whose title similarity is below alignTitleFloor are
// rejected: the local stays "unmatched" and the candidate slot
// surfaces as "missing". This lets a wrong-track-number-but-
// matching-title file pair correctly while keeping a totally
// unrelated file from being force-paired into a slot.
//
// Returns one TrackAlignment per local track (status = matched,
// mismatched, or unmatched) plus additional missing alignments
// for candidate tracks with no local file. The caller sums Score
// fields to get a release-level score.
func AlignTracks(locals []LocalTrack, cands []CandidateTrack) []TrackAlignment {
type pair struct {
li int
ci int
score float64
title float64
}
// Score every (local, cand) combination.
pairs := make([]pair, 0, len(locals)*len(cands))
for li, local := range locals {
for ci, cand := range cands {
pairs = append(pairs, pair{
li: li,
ci: ci,
score: trackDistance(local, cand),
title: titleSimilarity(local.Title, cand.Title),
})
}
}
// Sort descending by score — pick best pairs first. Insertion
// sort keeps the dependency surface zero; N^2 here is fine for
// album-sized inputs.
for i := 1; i < len(pairs); i++ {
for j := i; j > 0 && pairs[j].score > pairs[j-1].score; j-- {
pairs[j], pairs[j-1] = pairs[j-1], pairs[j]
}
}
localUsed := make([]bool, len(locals))
candUsed := make([]bool, len(cands))
alignments := make([]TrackAlignment, len(locals))
localMatched := 0
for _, p := range pairs {
if localMatched == len(locals) {
break
}
if localUsed[p.li] || candUsed[p.ci] {
continue
}
// Below the floor: this is the best pair available for both
// of these slots, but the title similarity is so low that
// pairing them would just be noise. Leave both unclaimed —
// they'll fall through to the unmatched/missing fixups below.
if p.title < alignTitleFloor {
continue
}
localUsed[p.li] = true
candUsed[p.ci] = true
localMatched++
l := locals[p.li]
c := cands[p.ci]
status := AlignmentMatched
if p.title < titleReject {
status = AlignmentMismatched
}
delta := l.LengthMillis - c.LengthMillis
if delta < 0 {
delta = -delta
}
alignments[p.li] = TrackAlignment{
LocalIndex: p.li,
LocalTitle: l.Title,
LocalLengthMillis: l.LengthMillis,
CandidatePosition: c.Position,
CandidateDiscNumber: c.DiscNumber,
CandidateTitle: c.Title,
CandidateMBID: c.MBID,
CandidateLength: c.LengthMillis,
TitleScore: p.title,
LengthDeltaMs: delta,
TrackNumberOK: l.TrackNumber > 0 && l.TrackNumber == c.Position,
Status: status,
}
}
// Local tracks left unclaimed → folder has them, candidate
// doesn't (or pairing was rejected by the floor).
for li, used := range localUsed {
if used {
continue
}
l := locals[li]
alignments[li] = TrackAlignment{
LocalIndex: li,
LocalTitle: l.Title,
LocalLengthMillis: l.LengthMillis,
Status: AlignmentUnmatched,
}
}
// Candidate tracks left unclaimed → candidate has them, folder
// doesn't.
for ci, used := range candUsed {
if used {
continue
}
c := cands[ci]
alignments = append(alignments, TrackAlignment{
LocalIndex: -1,
CandidatePosition: c.Position,
CandidateDiscNumber: c.DiscNumber,
CandidateTitle: c.Title,
CandidateMBID: c.MBID,
CandidateLength: c.LengthMillis,
Status: AlignmentMissing,
})
}
return alignments
}
+178
View File
@@ -0,0 +1,178 @@
package autotag_test
import (
"testing"
"yellowjacket/backend/autotag"
)
func TestAlignTracks_ExactMatch(t *testing.T) {
t.Parallel()
local := []autotag.LocalTrack{
{Title: "Come Together", TrackNumber: 1, LengthMillis: 259000},
{Title: "Something", TrackNumber: 2, LengthMillis: 183000},
}
cand := []autotag.CandidateTrack{
{Position: 1, Title: "Come Together", LengthMillis: 259000},
{Position: 2, Title: "Something", LengthMillis: 183000},
}
al := autotag.AlignTracks(local, cand)
if len(al) != 2 { //nolint:mnd
t.Fatalf("alignments = %d, want 2", len(al))
}
for i, a := range al {
if a.Status != autotag.AlignmentMatched {
t.Errorf("alignment %d: status = %q, want matched", i, a.Status)
}
if a.LocalIndex != i {
t.Errorf("alignment %d: LocalIndex = %d", i, a.LocalIndex)
}
}
}
// Folder has 2 tracks, candidate has 3 — the third candidate
// track surfaces as "missing" (candidate has it, folder doesn't).
func TestAlignTracks_CandidateMissingTrack(t *testing.T) {
t.Parallel()
local := []autotag.LocalTrack{
{Title: "Come Together", TrackNumber: 1, LengthMillis: 259000},
{Title: "Something", TrackNumber: 2, LengthMillis: 183000},
}
cand := []autotag.CandidateTrack{
{Position: 1, Title: "Come Together", LengthMillis: 259000},
{Position: 2, Title: "Something", LengthMillis: 183000},
{Position: 3, Title: "Here Comes the Sun", LengthMillis: 185000},
}
al := autotag.AlignTracks(local, cand)
if len(al) != 3 { //nolint:mnd
t.Fatalf("alignments = %d, want 3 (2 matched + 1 missing)", len(al))
}
var missing, matched int
for _, a := range al {
switch a.Status {
case autotag.AlignmentMatched:
matched++
case autotag.AlignmentMissing:
missing++
if a.CandidateTitle != "Here Comes the Sun" {
t.Errorf("missing alignment title = %q", a.CandidateTitle)
}
case autotag.AlignmentUnmatched, autotag.AlignmentMismatched:
t.Errorf("unexpected status %q", a.Status)
}
}
if matched != 2 || missing != 1 { //nolint:mnd
t.Errorf("matched=%d missing=%d, want 2/1", matched, missing)
}
}
// One local track has a low-but-non-zero similarity to the only
// candidate slot (above the floor, below titleReject) — pairs up
// as "mismatched" so the UI can flag the diff.
func TestAlignTracks_Mismatched(t *testing.T) {
t.Parallel()
// "yellow" vs "yellow submarine" normalises to a similarity of
// ~0.375 — above alignTitleFloor (0.30), below titleReject
// (0.60).
local := []autotag.LocalTrack{
{Title: "Yellow", TrackNumber: 1, LengthMillis: 100000},
}
cand := []autotag.CandidateTrack{
{Position: 1, Title: "Yellow Submarine", LengthMillis: 100000},
}
al := autotag.AlignTracks(local, cand)
if len(al) != 1 {
t.Fatalf("alignments = %d, want 1", len(al))
}
if al[0].Status != autotag.AlignmentMismatched {
t.Errorf("status = %q, want mismatched", al[0].Status)
}
}
// A folder track that is genuinely unrelated to anything in the
// candidate list should surface as "unmatched" rather than getting
// force-paired into a slot, even when only one candidate slot is
// available. The candidate slot becomes "missing" in turn.
func TestAlignTracks_RandomLocalTrack(t *testing.T) {
t.Parallel()
// Title similarity here is well below alignTitleFloor (0.30) —
// no shared word stems, no shared length, no shared structure.
local := []autotag.LocalTrack{
{Title: "Random Garbage Title", TrackNumber: 1, LengthMillis: 100000},
}
cand := []autotag.CandidateTrack{
{Position: 1, Title: "Specific Album Track", LengthMillis: 259000},
}
al := autotag.AlignTracks(local, cand)
if len(al) != 2 { //nolint:mnd
t.Fatalf("alignments = %d, want 2 (1 unmatched + 1 missing)", len(al))
}
var unmatched, missing int
for _, a := range al {
switch a.Status {
case autotag.AlignmentUnmatched:
unmatched++
case autotag.AlignmentMissing:
missing++
default:
t.Errorf("unexpected status %q", a.Status)
}
}
if unmatched != 1 || missing != 1 {
t.Errorf("unmatched=%d missing=%d, want 1/1", unmatched, missing)
}
}
// A folder track with the wrong track number but a matching title
// + length should still pair up — the wrong number becomes a diff
// the UI surfaces, not a reason to refuse pairing.
func TestAlignTracks_WrongTrackNumberStillPairs(t *testing.T) {
t.Parallel()
local := []autotag.LocalTrack{
{Title: "Come Together", TrackNumber: 7, LengthMillis: 259000},
}
cand := []autotag.CandidateTrack{
{Position: 1, Title: "Come Together", LengthMillis: 259000},
}
al := autotag.AlignTracks(local, cand)
if len(al) != 1 {
t.Fatalf("alignments = %d, want 1", len(al))
}
if al[0].Status != autotag.AlignmentMatched {
t.Errorf("status = %q, want matched (track-number diff is not a reject)", al[0].Status)
}
if al[0].TrackNumberOK {
t.Errorf("TrackNumberOK = true, want false (local#=7 vs candidate#=1)")
}
if al[0].CandidatePosition != 1 {
t.Errorf("CandidatePosition = %d, want 1", al[0].CandidatePosition)
}
}
+423
View File
@@ -0,0 +1,423 @@
package autotag
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"yellowjacket/backend/database/sql/sqlcgen"
)
// TagChanges mirrors tagwriter.TagChanges — redefined here so the
// autotag package does not import tagwriter (and so tests can
// construct changes without pulling in the write pipeline). The
// adapter in app wiring converts this to the concrete tagwriter
// shape.
type TagChanges map[string]any
// Field name constants matching tagwriter's canonical names. Keep
// these in sync with tagwriter/tagwriter.go — the runtime adapter
// passes the map through unchanged.
const (
FieldTitle = "title"
FieldArtist = "artist"
FieldAlbum = "album"
FieldAlbumArtist = "album_artist"
FieldYear = "year"
FieldTrackNumber = "track_number"
FieldDiscNumber = "disc_number"
FieldCoverArt = "cover_art"
)
// TagWriter is the subset of tagwriter.TagWriter the apply pipeline
// needs. Defined here so scorer_test.go can stub it and autotag
// stays import-acyclic with tagwriter.
type TagWriter interface {
WriteTrackTagsByPath(filePath string, changes TagChanges) error
}
// ApplyPlan summarises what Apply is about to do for one group.
// Returned from PrepareApply so the UI can preview (and, in the
// future, pass through a dry-run flag).
type ApplyPlan struct {
GroupKey string
Candidate Candidate
Tracks []TrackApply
}
// TrackApply is the per-track slice of an ApplyPlan: the local
// audio file, the aligned candidate track, and the changes the
// writer will actually emit.
type TrackApply struct {
Local LocalTrack
CandidateTrack CandidateTrack
Changes TagChanges
Aligned bool // false when no candidate track matched (skipped)
}
// ApplyResult summarises the outcome after the writes ran.
type ApplyResult struct {
GroupKey string
Succeeded int
Failed int
Failures []ApplyFailure
}
// ApplyFailure captures one track that failed during apply.
type ApplyFailure struct {
FilePath string
Error string
}
// ErrNoCandidate is returned when Apply is asked to apply a release
// MBID that isn't in the group's current candidate list.
var ErrNoCandidate = errors.New("autotag: candidate not found for apply")
// Applier runs the per-track tag writes + DB MBID updates when the
// user accepts a candidate. Cover-art embedding is delegated to a
// separate helper (CoverArtEmbedder) that's optional — tests pass
// nil to skip the CAA path.
type Applier struct {
q *sqlcgen.Queries
tw TagWriter
coverArt CoverArtEmbedder
log *slog.Logger
}
// CoverArtEmbedder is the autotag pipeline's view of cover-art
// fetching. Split into two operations so the Applier can fetch
// the release group's art exactly once per album and only consult
// each file's existing-art state per-track:
//
// - FetchArt is a network operation: hit CAA for the release
// group, validate dimensions, return the bytes ready to embed
// (or nil when CAA has nothing / the result is below the
// minimum size). Idempotent — call once per album.
// - HasEmbeddedArt is a per-file probe: read the local file's
// tags and report whether it already carries a picture. Cheap
// compared to FetchArt; called per track.
//
// The Applier merges FieldCoverArt into a track's changes only
// when FetchArt produced bytes AND HasEmbeddedArt returned false
// for that track — preserving the rule "never replace existing
// art".
type CoverArtEmbedder interface {
FetchArt(ctx context.Context, releaseGroupMBID string) ([]byte, error)
HasEmbeddedArt(filePath string) bool
}
// NewApplier wires up the apply pipeline. Pass coverArt=nil to
// skip CAA integration (useful for tests).
func NewApplier(
q *sqlcgen.Queries,
tw TagWriter,
coverArt CoverArtEmbedder,
logger *slog.Logger,
) *Applier {
return &Applier{q: q, tw: tw, coverArt: coverArt, log: logger}
}
// BuildPlan constructs the per-track change set for a given
// candidate without executing any writes. The UI can call this to
// preview the diff before confirming. When releaseMBID doesn't
// match any candidate in score, ErrNoCandidate is returned.
func (a *Applier) BuildPlan(
score *GroupScore, releaseMBID string,
) (*ApplyPlan, error) {
var picked *Candidate
for i := range score.Candidates {
c := &score.Candidates[i]
if c.ReleaseMBID == releaseMBID ||
(releaseMBID == "" && i == 0) ||
(c.ReleaseMBID == "" && c.ReleaseGroupMBID == releaseMBID) {
picked = c
break
}
}
if picked == nil {
return nil, ErrNoCandidate
}
tracks := make([]TrackApply, 0, len(score.LocalTracks))
for li, local := range score.LocalTracks {
align := findAlignment(picked.Alignments, li)
if align == nil || align.Status == AlignmentUnmatched {
tracks = append(tracks, TrackApply{Local: local, Aligned: false})
continue
}
candTrack := CandidateTrack{
Position: align.CandidatePosition,
DiscNumber: align.CandidateDiscNumber,
Title: align.CandidateTitle,
LengthMillis: align.CandidateLength,
MBID: align.CandidateMBID,
}
tracks = append(tracks, TrackApply{
Local: local,
CandidateTrack: candTrack,
Changes: buildChanges(local, *picked, candTrack),
Aligned: true,
})
}
return &ApplyPlan{
GroupKey: score.GroupKey,
Candidate: *picked,
Tracks: tracks,
}, nil
}
// ApplyProgress is the optional per-track callback Apply invokes
// after each track is processed. Hosts pass nil to opt out (used
// in tests and the legacy synchronous Apply path); the
// autotagservice layer wires this to a Wails event emit so the
// review UI can render a progress ring.
//
// counts: current is 1-indexed (the track that just finished),
// total is len(aligned tracks), succeeded/failed are running
// totals.
type ApplyProgress func(current, total, succeeded, failed int)
// Apply runs the plan: for each aligned track, write file tags,
// update DB MBID columns, stamp audio_files.tag_status =
// 'user_confirmed'. Cover art is fetched once for the whole
// album (idempotent) and merged into each track that has no
// existing embedded art. Tracks whose changes map ends up empty
// are skipped silently and counted as succeeded — that keeps a
// "Leave as-is on a perfect match" path from spuriously failing
// every track on errNoChanges.
//
// onProgress is called once per aligned track as it completes
// (success or failure); pass nil to opt out.
//
// On completion, tagging_items status flips to 'confirmed' if at
// least one track succeeded; failed-everything jobs leave the
// group in 'pending' so it remains in the review queue.
func (a *Applier) Apply(
ctx context.Context, plan *ApplyPlan, onProgress ApplyProgress,
) (*ApplyResult, error) {
result := &ApplyResult{GroupKey: plan.GroupKey}
// Fetch the release-group's cover art once up front — same
// album, same JPEG, no point hitting CAA per track. The
// per-file existing-art check still runs inside the loop so
// we never overwrite an existing picture.
var albumArt []byte
if a.coverArt != nil && plan.Candidate.ReleaseGroupMBID != "" {
art, err := a.coverArt.FetchArt(ctx, plan.Candidate.ReleaseGroupMBID)
if err != nil {
a.log.Warn(
"cover art fetch failed — proceeding without embed",
"release_group_mbid", plan.Candidate.ReleaseGroupMBID, "err", err,
)
} else {
albumArt = art
}
}
// Total of aligned tracks for progress reporting.
total := 0
for _, tr := range plan.Tracks {
if tr.Aligned {
total++
}
}
current := 0
for _, tr := range plan.Tracks {
if !tr.Aligned {
continue
}
current++
changes := tr.Changes
if albumArt != nil && a.coverArt != nil && !a.coverArt.HasEmbeddedArt(tr.Local.FilePath) {
// Copy the map so we don't mutate a slice of shared
// maps on subsequent iterations.
merged := make(TagChanges, len(changes)+1)
for k, v := range changes {
merged[k] = v
}
merged[FieldCoverArt] = albumArt
changes = merged
}
// Skip the file write entirely when no field would change.
// The tagwriter would return errNoChanges and we'd record
// it as a failure — wrong outcome for a track that's
// already correct. The DB sync still runs so MBIDs land.
if len(changes) > 0 {
if err := a.tw.WriteTrackTagsByPath(tr.Local.FilePath, changes); err != nil {
result.Failed++
result.Failures = append(result.Failures, ApplyFailure{
FilePath: tr.Local.FilePath,
Error: err.Error(),
})
a.log.Warn(
"apply: tag write failed",
"path", tr.Local.FilePath, "err", err,
)
if onProgress != nil {
onProgress(current, total, result.Succeeded, result.Failed)
}
continue
}
}
if err := a.syncDBMBIDs(ctx, tr, plan.Candidate); err != nil {
a.log.Warn(
"apply: MBID DB sync failed (file tags written OK)",
"path", tr.Local.FilePath, "err", err,
)
}
if err := a.q.SetAudioFileTagStatus(ctx, sqlcgen.SetAudioFileTagStatusParams{
TagStatus: "user_confirmed",
ID: tr.Local.AudioFileID,
}); err != nil {
a.log.Warn(
"apply: tag_status update failed",
"audio_file_id", tr.Local.AudioFileID, "err", err,
)
}
result.Succeeded++
if onProgress != nil {
onProgress(current, total, result.Succeeded, result.Failed)
}
}
// Flip the group's status only when at least one write landed.
if result.Succeeded > 0 {
if err := a.q.SetTaggingItemStatus(ctx, sqlcgen.SetTaggingItemStatusParams{
Status: "confirmed",
GroupKey: plan.GroupKey,
}); err != nil {
return result, fmt.Errorf("mark group confirmed: %w", err)
}
}
return result, nil
}
// syncDBMBIDs writes the recording + release-group MBIDs from the
// candidate to the local DB so the app's MBID-driven features (MB
// browser "in library" indicator, smart-playlist MBID rule fields)
// pick up the new identity without needing a rescan. File-level
// MBID frames are *not* written by tagwriter today — they're a
// follow-up; the DB copy is what the app reads.
func (a *Applier) syncDBMBIDs(
ctx context.Context, tr TrackApply, cand Candidate,
) error {
// Look up recording row via audio_file.
af, err := a.q.GetAudioFile(ctx, tr.Local.AudioFileID)
if err != nil {
return fmt.Errorf("get audio_file: %w", err)
}
if tr.CandidateTrack.MBID != "" {
if err := a.q.SetRecordingMBID(ctx, sqlcgen.SetRecordingMBIDParams{
Mbid: sql.NullString{String: tr.CandidateTrack.MBID, Valid: true},
ID: af.RecordingID,
}); err != nil {
return fmt.Errorf("set recording mbid: %w", err)
}
}
if cand.ReleaseGroupMBID != "" {
rgID, err := a.q.GetRecordingReleaseGroupID(ctx, af.RecordingID)
if err == nil && rgID > 0 {
if err := a.q.SetReleaseGroupMBID(ctx, sqlcgen.SetReleaseGroupMBIDParams{
Mbid: sql.NullString{String: cand.ReleaseGroupMBID, Valid: true},
ID: rgID,
}); err != nil {
return fmt.Errorf("set release group mbid: %w", err)
}
// Stamp the release-group's original-release year too —
// this is what the tracklist / smart-playlist year rule
// surfaces by default once the user accepts a candidate.
if year := parseYear(cand.OriginalDate); year > 0 {
if err := a.q.SetReleaseGroupOriginalYear(
ctx, sqlcgen.SetReleaseGroupOriginalYearParams{
OriginalYear: sql.NullInt64{Int64: int64(year), Valid: true},
ID: rgID,
},
); err != nil {
return fmt.Errorf("set release group original year: %w", err)
}
}
}
}
return nil
}
// findAlignment returns the alignment row that corresponds to the
// given local-track index, or nil if the track wasn't aligned
// (status=missing).
func findAlignment(alignments []TrackAlignment, localIdx int) *TrackAlignment {
for i := range alignments {
if alignments[i].LocalIndex == localIdx {
return &alignments[i]
}
}
return nil
}
// buildChanges returns the whitelisted field diff for one track
// given its aligned candidate track and the parent release. Only
// non-empty candidate values become changes — we don't overwrite
// local tags with empty strings from incomplete MB data.
func buildChanges(
local LocalTrack, cand Candidate, track CandidateTrack,
) TagChanges {
changes := make(TagChanges, 8) //nolint:mnd
if track.Title != "" && track.Title != local.Title {
changes[FieldTitle] = track.Title
}
if cand.ArtistCredit != "" {
changes[FieldArtist] = cand.ArtistCredit
changes[FieldAlbumArtist] = cand.ArtistCredit
}
if cand.Title != "" {
changes[FieldAlbum] = cand.Title
}
if year := parseYear(cand.Date); year > 0 {
changes[FieldYear] = year
}
if track.Position > 0 && track.Position != local.TrackNumber {
changes[FieldTrackNumber] = track.Position
}
if track.DiscNumber > 0 && track.DiscNumber != local.DiscNumber {
changes[FieldDiscNumber] = track.DiscNumber
}
return changes
}
+383
View File
@@ -0,0 +1,383 @@
package autotag_test
import (
"context"
"database/sql"
"log/slog"
"sync"
"testing"
"yellowjacket/backend/autotag"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
)
// recordingTagWriter is a TagWriter stub that records each call's
// path + changes for assertion. Returns nil from
// WriteTrackTagsByPath unless writeErr is set, in which case every
// call returns it.
type recordingTagWriter struct {
mu sync.Mutex
calls []recordedTagWrite
writeErr error
}
type recordedTagWrite struct {
filePath string
changes autotag.TagChanges
}
func (w *recordingTagWriter) WriteTrackTagsByPath(
filePath string, changes autotag.TagChanges,
) error {
w.mu.Lock()
defer w.mu.Unlock()
w.calls = append(w.calls, recordedTagWrite{filePath: filePath, changes: changes})
return w.writeErr
}
// stubCoverArt is a CoverArtEmbedder stub that counts calls so we
// can verify FetchArt runs exactly once per Apply. hasEmbedded
// is the response HasEmbeddedArt returns for every file; art is
// what FetchArt returns.
type stubCoverArt struct {
mu sync.Mutex
fetchCalls int
embeddedCalls int
art []byte
hasEmbedded bool
releaseSeen string
}
func (s *stubCoverArt) FetchArt(_ context.Context, releaseGroupMBID string) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.fetchCalls++
s.releaseSeen = releaseGroupMBID
return s.art, nil
}
func (s *stubCoverArt) HasEmbeddedArt(_ string) bool {
s.mu.Lock()
defer s.mu.Unlock()
s.embeddedCalls++
return s.hasEmbedded
}
// silentLogger swallows log output during tests.
func silentLogger() *slog.Logger {
return slog.New(slog.DiscardHandler)
}
// seedAudioFiles inserts the minimum DB rows the apply pipeline
// touches: artist credit, recordings, release group, RG-recording
// links, and audio_files. Returns the audio_file IDs in track
// order so the test can build matching ApplyPlan entries.
func seedAudioFiles(
t *testing.T, db *database.DB, groupKey string, paths []string,
) []sqlcgen.AudioFile {
t.Helper()
q := db.Queries
ctx := db.Ctx
ac, err := q.UpsertArtistCredit(ctx, "Test Artist")
if err != nil {
t.Fatalf("upsert artist credit: %v", err)
}
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
Name: "Test Album",
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
})
if err != nil {
t.Fatalf("upsert rg: %v", err)
}
out := make([]sqlcgen.AudioFile, 0, len(paths))
for i, p := range paths {
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
Name: p,
ArtistCreditID: ac.ID,
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
})
if err != nil {
t.Fatalf("create recording: %v", err)
}
if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{
ReleaseGroupID: rg.ID,
RecordingID: rec.ID,
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
}); err != nil {
t.Fatalf("link rg recording: %v", err)
}
af, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
FilePath: p,
LengthMilliseconds: 100000,
FileTypeID: 0,
RecordingID: rec.ID,
Basename: p,
LibraryID: 0,
GroupKey: groupKey,
TagStatus: "untagged",
})
if err != nil {
t.Fatalf("create audio file: %v", err)
}
out = append(out, af)
}
if _, err := db.ExecContext(`
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
VALUES (?, 0, ?, 'Test Album', 'Test Artist', 0, 'pending')
`, groupKey, len(paths)); err != nil {
t.Fatalf("insert tagging_item: %v", err)
}
return out
}
// TestApply_SkipsNoOpChanges verifies that a track whose Changes
// map is empty (nothing would change in the file) doesn't trigger
// a WriteTrackTagsByPath call but still counts as a successful
// track in the result. Today the writer would return errNoChanges
// for an empty changes map; the apply would record a spurious
// failure. This test pins the new behaviour: skip cleanly.
func TestApply_SkipsNoOpChanges(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
files := seedAudioFiles(t, db, "g-noop", []string{"/tmp/a.mp3", "/tmp/b.mp3"})
tw := &recordingTagWriter{}
cover := &stubCoverArt{} // no art to embed
applier := autotag.NewApplier(db.Queries, tw, cover, silentLogger())
plan := &autotag.ApplyPlan{
GroupKey: "g-noop",
Candidate: autotag.Candidate{Title: "Test Album"}, // no RG MBID → no cover fetch
Tracks: []autotag.TrackApply{
{
Local: autotag.LocalTrack{
AudioFileID: files[0].ID, FilePath: "/tmp/a.mp3",
},
Changes: autotag.TagChanges{},
Aligned: true,
},
{
Local: autotag.LocalTrack{
AudioFileID: files[1].ID, FilePath: "/tmp/b.mp3",
},
Changes: autotag.TagChanges{autotag.FieldTitle: "New"},
Aligned: true,
},
},
}
result, err := applier.Apply(context.Background(), plan, nil)
if err != nil {
t.Fatalf("apply: %v", err)
}
if result.Succeeded != 2 { //nolint:mnd
t.Errorf("succeeded = %d, want 2", result.Succeeded)
}
if result.Failed != 0 {
t.Errorf("failed = %d, want 0", result.Failed)
}
if len(tw.calls) != 1 {
t.Errorf("WriteTrackTagsByPath calls = %d, want 1 (no-op skipped)", len(tw.calls))
}
if len(tw.calls) > 0 && tw.calls[0].filePath != "/tmp/b.mp3" {
t.Errorf("call 0 path = %q, want /tmp/b.mp3 (the only one with changes)",
tw.calls[0].filePath)
}
}
// TestApply_FetchArtCalledOncePerAlbum verifies that the cover-art
// network fetch happens exactly once per Apply, regardless of how
// many tracks need it. HasEmbeddedArt is still called per track
// so we never overwrite existing art.
func TestApply_FetchArtCalledOncePerAlbum(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
paths := []string{"/tmp/x1.mp3", "/tmp/x2.mp3", "/tmp/x3.mp3", "/tmp/x4.mp3"}
files := seedAudioFiles(t, db, "g-art", paths)
tw := &recordingTagWriter{}
cover := &stubCoverArt{art: []byte("fake-jpeg")} // network bytes available
applier := autotag.NewApplier(db.Queries, tw, cover, silentLogger())
tracks := make([]autotag.TrackApply, 0, len(paths))
for i, p := range paths {
tracks = append(tracks, autotag.TrackApply{
Local: autotag.LocalTrack{
AudioFileID: files[i].ID, FilePath: p,
},
Changes: autotag.TagChanges{autotag.FieldTitle: "T"},
Aligned: true,
})
}
plan := &autotag.ApplyPlan{
GroupKey: "g-art",
Candidate: autotag.Candidate{
Title: "Test Album",
ReleaseGroupMBID: "rg-fake",
},
Tracks: tracks,
}
result, err := applier.Apply(context.Background(), plan, nil)
if err != nil {
t.Fatalf("apply: %v", err)
}
if result.Succeeded != len(paths) {
t.Errorf("succeeded = %d, want %d", result.Succeeded, len(paths))
}
if cover.fetchCalls != 1 {
t.Errorf("FetchArt calls = %d, want 1 (memoised per album)", cover.fetchCalls)
}
if cover.embeddedCalls != len(paths) {
t.Errorf("HasEmbeddedArt calls = %d, want %d (one per track)",
cover.embeddedCalls, len(paths))
}
if cover.releaseSeen != "rg-fake" {
t.Errorf("FetchArt rg = %q, want rg-fake", cover.releaseSeen)
}
// Every track's WriteTrackTagsByPath call should carry the
// cover-art bytes since none of the files had embedded art.
for i, c := range tw.calls {
if _, ok := c.changes[autotag.FieldCoverArt]; !ok {
t.Errorf("call %d: no cover_art in changes", i)
}
}
}
// TestApply_SkipsCoverWhenAlreadyEmbedded verifies that
// HasEmbeddedArt=true blocks the per-track cover-art merge — never
// replace existing art is the invariant.
func TestApply_SkipsCoverWhenAlreadyEmbedded(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
files := seedAudioFiles(t, db, "g-emb", []string{"/tmp/y1.mp3"})
tw := &recordingTagWriter{}
// File reports it already has embedded art; FetchArt still
// runs (one call, then memoised) but its bytes never land in
// the change map.
cover := &stubCoverArt{art: []byte("bytes"), hasEmbedded: true}
applier := autotag.NewApplier(db.Queries, tw, cover, silentLogger())
plan := &autotag.ApplyPlan{
GroupKey: "g-emb",
Candidate: autotag.Candidate{
Title: "Test Album",
ReleaseGroupMBID: "rg-emb",
},
Tracks: []autotag.TrackApply{
{
Local: autotag.LocalTrack{
AudioFileID: files[0].ID, FilePath: "/tmp/y1.mp3",
},
Changes: autotag.TagChanges{autotag.FieldTitle: "T"},
Aligned: true,
},
},
}
if _, err := applier.Apply(context.Background(), plan, nil); err != nil {
t.Fatalf("apply: %v", err)
}
if len(tw.calls) != 1 {
t.Fatalf("write calls = %d, want 1", len(tw.calls))
}
if _, ok := tw.calls[0].changes[autotag.FieldCoverArt]; ok {
t.Errorf("cover_art present in changes; should have been skipped")
}
}
// TestApply_ProgressCallbackInvocations verifies onProgress is
// called once per aligned track and that current/total reflect
// the position in the sequence.
func TestApply_ProgressCallbackInvocations(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
paths := []string{"/tmp/p1.mp3", "/tmp/p2.mp3", "/tmp/p3.mp3"}
files := seedAudioFiles(t, db, "g-prog", paths)
applier := autotag.NewApplier(db.Queries, &recordingTagWriter{}, nil, silentLogger())
tracks := make([]autotag.TrackApply, 0, len(paths))
for i, p := range paths {
tracks = append(tracks, autotag.TrackApply{
Local: autotag.LocalTrack{
AudioFileID: files[i].ID, FilePath: p,
},
Changes: autotag.TagChanges{autotag.FieldTitle: "T"},
Aligned: true,
})
}
plan := &autotag.ApplyPlan{
GroupKey: "g-prog",
Candidate: autotag.Candidate{Title: "Test Album"},
Tracks: tracks,
}
type call struct {
current, total, succeeded, failed int
}
var calls []call
if _, err := applier.Apply(
context.Background(),
plan,
func(current, total, succeeded, failed int) {
calls = append(calls, call{current, total, succeeded, failed})
},
); err != nil {
t.Fatalf("apply: %v", err)
}
if len(calls) != len(paths) {
t.Fatalf("progress callbacks = %d, want %d", len(calls), len(paths))
}
for i, c := range calls {
if c.current != i+1 {
t.Errorf("call %d: current = %d, want %d", i, c.current, i+1)
}
if c.total != len(paths) {
t.Errorf("call %d: total = %d, want %d", i, c.total, len(paths))
}
}
}
+155
View File
@@ -0,0 +1,155 @@
package autotag
// levenshtein returns the Levenshtein edit distance between a and
// b, operating on runes so multi-byte characters count as one edit.
// Allocates a single O(min(len)) scratch slice.
func levenshtein(a, b string) int {
ra := []rune(a)
rb := []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
if len(ra) > len(rb) {
ra, rb = rb, ra
}
prev := make([]int, len(ra)+1)
for i := range prev {
prev[i] = i
}
for j := 1; j <= len(rb); j++ {
curr0 := prev[0]
prev[0] = j
for i := 1; i <= len(ra); i++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
newVal := min3(
prev[i]+1, // deletion
prev[i-1]+1, // insertion
curr0+cost, // substitution
)
curr0 = prev[i]
prev[i] = newVal
}
}
return prev[len(ra)]
}
func min3(a, b, c int) int {
if a < b {
if a < c {
return a
}
return c
}
if b < c {
return b
}
return c
}
// titleSimilarity returns a score in [0, 1] from normalized edit
// distance. 1.0 means identical after normalization, 0.0 means
// fully dissimilar. Both sides are normalized inside.
func titleSimilarity(a, b string) float64 {
na := Normalize(a)
nb := Normalize(b)
if na == "" && nb == "" {
return 1.0
}
longest := len(na)
if len(nb) > longest {
longest = len(nb)
}
if longest == 0 {
return 0.0
}
dist := levenshtein(na, nb)
return 1.0 - float64(dist)/float64(longest)
}
// Scoring weights for the per-track distance function. Local reads
// only — never written at runtime, so no mutex. Values stay small
// so a future tuning pass can nudge them without rescaling.
const (
weightTitle = 0.60
weightLength = 0.30
weightTrackNumber = 0.10
// Length deltas at or below lengthExactMs score 1.0 — matches
// the frontend's "subtle drift" threshold so anything the UI
// hides also doesn't count against the score. Past the
// threshold the penalty scales with delta / candidateMs (i.e.
// percentage of candidate-track length): a 5 s delta on a
// 4 min track is small, the same delta on a 30 s interlude is
// huge. At lengthFullyWrongPct of candidate length the score
// hits zero; beyond that it stays clamped to zero.
lengthExactMs int64 = 2000
lengthFullyWrongPct float64 = 0.20
// A title below titleReject has too little signal for this
// alignment to count as matched.
titleReject = 0.60
)
// lengthScore returns 1.0 for deltas <= lengthExactMs, 0.0 for
// deltas >= lengthFullyWrongPct of the candidate length, linear
// in delta-as-percentage-of-candidate-length between. When
// either side is zero (unknown), returns 0.5 so length is treated
// as neutral.
func lengthScore(localMs, candidateMs int64) float64 {
if localMs <= 0 || candidateMs <= 0 {
return 0.5
}
delta := localMs - candidateMs
if delta < 0 {
delta = -delta
}
if delta <= lengthExactMs {
return 1.0
}
pct := float64(delta) / float64(candidateMs)
if pct >= lengthFullyWrongPct {
return 0.0
}
return 1.0 - pct/lengthFullyWrongPct
}
// trackDistance scores how well one local track aligns with one
// candidate track. Higher is better. Caller decides what to do
// with the result — this function has no threshold.
func trackDistance(local LocalTrack, cand CandidateTrack) float64 {
title := titleSimilarity(local.Title, cand.Title)
length := lengthScore(local.LengthMillis, cand.LengthMillis)
var trackOK float64
if local.TrackNumber > 0 && local.TrackNumber == cand.Position {
trackOK = 1.0
}
return title*weightTitle + length*weightLength + trackOK*weightTrackNumber
}
+132
View File
@@ -0,0 +1,132 @@
package autotag
import "testing"
func TestLevenshtein(t *testing.T) {
t.Parallel()
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"abc", "", 3},
{"", "abc", 3},
{"kitten", "sitting", 3},
{"beyoncé", "beyonce", 1},
{"abbey road", "abbey road", 0},
}
for _, tc := range cases {
got := levenshtein(tc.a, tc.b)
if got != tc.want {
t.Errorf("levenshtein(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
func TestTitleSimilarity(t *testing.T) {
t.Parallel()
cases := []struct {
a, b string
minScore float64
}{
{"Hey Jude", "Hey Jude", 1.00},
{"Hey Jude", "Hey Jude (Remastered 2009)", 1.00}, // qualifier stripped
{"Hey Jude", "HEY JUDE!", 1.00}, // case + punct
{"Hey Jude", "Hay Jude", 0.85}, // one char off
{"Hey Jude", "Let It Be", 0.00}, // different
}
for _, tc := range cases {
got := titleSimilarity(tc.a, tc.b)
if tc.minScore == 0 {
if got > 0.5 { //nolint:mnd
t.Errorf("titleSimilarity(%q, %q) = %.2f, expected low", tc.a, tc.b, got)
}
continue
}
if got < tc.minScore {
t.Errorf(
"titleSimilarity(%q, %q) = %.2f, want >= %.2f",
tc.a, tc.b, got, tc.minScore,
)
}
}
}
func TestLengthScore(t *testing.T) {
t.Parallel()
cases := []struct {
local, cand int64
want float64
}{
{200000, 200000, 1.0}, // exact
{200000, 200500, 1.0}, // 0.5s — under 2s threshold
{200000, 201900, 1.0}, // 1.9s — under 2s threshold
{200000, 202000, 1.0}, // exactly 2s — still full credit
{0, 200000, 0.5}, // unknown local → neutral
{200000, 0, 0.5}, // unknown candidate → neutral
// Past 2s, score scales by delta / candidateMs. 20% of
// candidate length = fully wrong (0.0).
// 240s candidate, 12s delta = 5% → 1 - 5/20 = 0.75.
{240000 + 12000, 240000, 0.75},
// 240s candidate, 24s delta = 10% → 1 - 10/20 = 0.50.
{240000 + 24000, 240000, 0.50},
// 240s candidate, 48s delta = 20% → clamped to 0.
{240000 + 48000, 240000, 0.0},
// 240s candidate, 72s delta = 30% → still 0 (clamped).
{240000 + 72000, 240000, 0.0},
// Same absolute delta hits short tracks harder.
// 60s candidate, 6s delta = 10% → 0.50.
{66000, 60000, 0.50},
// 60s candidate, 12s delta = 20% → 0.0.
{72000, 60000, 0.0},
}
for _, tc := range cases {
got := lengthScore(tc.local, tc.cand)
if diff := got - tc.want; diff < -0.05 || diff > 0.05 { //nolint:mnd
t.Errorf(
"lengthScore(%d, %d) = %.3f, want %.3f ± 0.05",
tc.local, tc.cand, got, tc.want,
)
}
}
}
func TestTrackDistance(t *testing.T) {
t.Parallel()
local := LocalTrack{
Title: "Hey Jude",
TrackNumber: 1,
LengthMillis: 431000,
}
exact := CandidateTrack{
Position: 1,
Title: "Hey Jude",
LengthMillis: 431000,
}
if got := trackDistance(local, exact); got < 0.99 {
t.Errorf("exact match = %.2f, want ~1.0", got)
}
wrong := CandidateTrack{
Position: 5,
Title: "Yesterday",
LengthMillis: 125000,
}
if got := trackDistance(local, wrong); got > 0.20 {
t.Errorf("wrong match = %.2f, want < 0.2", got)
}
}
+47
View File
@@ -0,0 +1,47 @@
// Package autotag provides the shared primitives used by the
// MusicBrainz autotagger: deterministic album-group keys, scoring,
// and (in later phases) MB orchestration.
package autotag
import (
"crypto/sha1" //nolint:gosec // non-crypto deterministic grouping key.
"encoding/hex"
"path/filepath"
"strconv"
"strings"
)
// GroupKey returns a deterministic album-group identifier for the
// file at filePath belonging to libraryID, carrying the given disc
// number.
//
// The key is a lower-case hex SHA-1 over
//
// libraryID || 0 || normalized_parent_dir || 0 || disc_number
//
// where the parent directory is lower-cased. The folder is taken
// as the album boundary — including the album tag string would
// fragment albums whose tracks carry slightly different tags
// (`Abbey Road` vs `Abbey Road (Remastered 2009)`, etc.). The
// album name is still surfaced in `tagging_items.album_name` for
// the review UI; it just doesn't decide grouping.
//
// Using SHA-1 matches the codebase's existing non-crypto
// deterministic-key convention; collision risk at album-group
// cardinality is irrelevant.
func GroupKey(
libraryID int64,
filePath string,
discNumber int,
) string {
parentDir := strings.ToLower(filepath.Dir(filePath))
h := sha1.New() //nolint:gosec // see package doc — grouping only.
h.Write([]byte(strconv.FormatInt(libraryID, 10)))
h.Write([]byte{0})
h.Write([]byte(parentDir))
h.Write([]byte{0})
h.Write([]byte(strconv.Itoa(discNumber)))
return hex.EncodeToString(h.Sum(nil))
}
+106
View File
@@ -0,0 +1,106 @@
package autotag_test
import (
"regexp"
"testing"
"yellowjacket/backend/autotag"
)
var hexSHA1 = regexp.MustCompile(`^[0-9a-f]{40}$`)
func TestGroupKey_FormatAndDeterminism(t *testing.T) {
t.Parallel()
key := autotag.GroupKey(1, "/music/Artist/Album/01.mp3", 1)
if !hexSHA1.MatchString(key) {
t.Fatalf("expected 40-char lowercase hex, got %q", key)
}
again := autotag.GroupKey(1, "/music/Artist/Album/01.mp3", 1)
if key != again {
t.Fatalf("GroupKey is not deterministic: %q vs %q", key, again)
}
}
func TestGroupKey_CaseInsensitiveParent(t *testing.T) {
t.Parallel()
a := autotag.GroupKey(1, "/Music/Artist/ALBUM/01.mp3", 1)
b := autotag.GroupKey(1, "/music/artist/album/01.mp3", 1)
if a != b {
t.Fatalf("parent dir case should not affect key: %q vs %q", a, b)
}
}
func TestGroupKey_AlbumTagDoesNotAffectKey(t *testing.T) {
t.Parallel()
// Folder = album. Tracks in the same folder MUST share a key
// regardless of any per-track variation in the album tag. This
// is the bug-fix this test guards against — earlier versions
// included the album tag in the hash, which fragmented albums
// whose tracks carried slightly different tags.
siblings := []string{
"/music/Artist/Album/01.mp3",
"/music/Artist/Album/02.mp3",
"/music/Artist/Album/03.mp3",
}
keys := make(map[string]struct{})
for _, p := range siblings {
keys[autotag.GroupKey(1, p, 0)] = struct{}{}
}
if len(keys) != 1 {
t.Fatalf(
"siblings in the same folder should share a key, got %d distinct: %v",
len(keys), keys,
)
}
}
func TestGroupKey_DistinctInputsDiffer(t *testing.T) {
t.Parallel()
base := autotag.GroupKey(1, "/music/Artist/Album/01.mp3", 1)
cases := map[string]string{
"different library": autotag.GroupKey(2, "/music/Artist/Album/01.mp3", 1),
"different parent": autotag.GroupKey(1, "/music/Artist/Other/01.mp3", 1),
"different disc": autotag.GroupKey(1, "/music/Artist/Album/01.mp3", 2),
"same sibling track": autotag.GroupKey(1, "/music/Artist/Album/02.mp3", 1),
}
for name, got := range cases {
if name == "same sibling track" {
if got != base {
t.Errorf("%s: expected same key as sibling track, got %q vs %q", name, got, base)
}
continue
}
if got == base {
t.Errorf("%s: expected distinct key, both %q", name, got)
}
}
}
func TestGroupKey_AmbiguityBoundary(t *testing.T) {
t.Parallel()
// Ensure the null-byte separator actually prevents "a|b" vs "ab|"
// ambiguity: if the hash naively concatenated with no separator,
// these two would collide.
a := autotag.GroupKey(1, "/a/b/01.mp3", 0)
b := autotag.GroupKey(1, "/a/bc/01.mp3", 0)
if a == b {
t.Fatalf(
"expected null-byte separator to disambiguate concatenations, both %q",
a,
)
}
}
+133
View File
@@ -0,0 +1,133 @@
package autotag
import (
"context"
"fmt"
"yellowjacket/backend/database/sql/sqlcgen"
)
// LocalResolver turns a tagging group's album name into zero-cost
// candidate releases by looking for local release_groups (with
// MBIDs) whose normalized name matches. The user's own tagged
// albums become free candidates — if they already have another
// library where the same album was tagged correctly, reuse that.
//
// The resolver talks to the DB through the sqlc-generated Queries
// type, not the database package's DB wrapper — keeps the import
// graph acyclic with the database package (which already depends
// on autotag.GroupKey for migration backfills).
type LocalResolver struct {
q *sqlcgen.Queries
}
// NewLocalResolver returns a resolver bound to the given Queries.
func NewLocalResolver(q *sqlcgen.Queries) *LocalResolver {
return &LocalResolver{q: q}
}
// LocalTracksForGroup returns the local audio files in the given
// tagging group, projected into the scorer-ready shape.
func (r *LocalResolver) LocalTracksForGroup(
ctx context.Context, groupKey string,
) ([]LocalTrack, error) {
rows, err := r.q.ListAudioFilesInTaggingGroup(ctx, groupKey)
if err != nil {
return nil, fmt.Errorf("list local tracks: %w", err)
}
out := make([]LocalTrack, 0, len(rows))
for _, row := range rows {
out = append(out, LocalTrack{
AudioFileID: row.ID,
FilePath: row.FilePath,
Title: row.Title,
Artist: row.ArtistName,
TrackNumber: int(row.TrackNumber),
DiscNumber: int(row.DiscNumber),
LengthMillis: row.LengthMilliseconds,
RecordingMBID: row.RecordingMbid,
})
}
return out, nil
}
// ResolveLocal returns candidate releases sourced from the local
// DB's release_groups rows (filtered to those carrying an MBID)
// whose normalized name matches the tagging item's album name.
// No network calls. Candidates carry all tracks flat; caller runs
// AlignTracks on each to produce per-track alignments.
func (r *LocalResolver) ResolveLocal(
ctx context.Context, albumName string,
) ([]Candidate, error) {
if albumName == "" {
return nil, nil
}
rows, err := r.q.ListLocalReleaseGroupCandidates(ctx, albumName)
if err != nil {
return nil, fmt.Errorf("list local candidates: %w", err)
}
normalizedTarget := Normalize(albumName)
byID := make(map[int64]*Candidate, 4) //nolint:mnd
tracksByID := make(map[int64][]CandidateTrack, 4) //nolint:mnd
for _, row := range rows {
// Case-insensitive SQL match is a cheap pre-filter; we
// still apply our full normalization rule in Go to reject
// false positives like "Greatest Hits" vs "Greatest Hits".
if Normalize(row.AlbumName) != normalizedTarget {
continue
}
if _, ok := byID[row.ReleaseGroupID]; !ok {
byID[row.ReleaseGroupID] = localCandidate(row)
}
tracksByID[row.ReleaseGroupID] = append(
tracksByID[row.ReleaseGroupID],
CandidateTrack{
Position: int(row.TrackNumber),
DiscNumber: int(row.DiscNumber),
Title: row.TrackTitle,
LengthMillis: row.LengthMilliseconds,
MBID: row.RecordingMbid,
},
)
}
out := make([]Candidate, 0, len(byID))
for id, c := range byID {
c.Tracks = tracksByID[id]
c.TrackCount = len(c.Tracks)
out = append(out, *c)
}
return out, nil
}
// localCandidate converts one sqlc row (minus track-level fields)
// into a Candidate shell. Track fields and alignments are filled
// in by the caller.
func localCandidate(row sqlcgen.ListLocalReleaseGroupCandidatesRow) *Candidate {
date := ""
if row.Year > 0 {
date = fmt.Sprintf("%04d", row.Year)
}
mbid := ""
if row.ReleaseGroupMbid.Valid {
mbid = row.ReleaseGroupMbid.String
}
return &Candidate{
ReleaseGroupMBID: mbid,
Title: row.AlbumName,
ArtistCredit: row.ArtistCredit,
Date: date,
Source: SourceLocal,
Provenance: "local",
}
}
+346
View File
@@ -0,0 +1,346 @@
package autotag
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
)
// MBReleaseGroupHit is the minimal projection of a MusicBrainz
// release-group search result that the scorer consumes. Defined
// here (rather than imported from explore) to keep the autotag
// package's external surface small and swappable.
type MBReleaseGroupHit struct {
MBID string
Title string
ArtistCredit string
FirstDate string
PrimaryType string
}
// MBRelease is the scorer's projection of a MusicBrainz release
// (one specific edition with its track list).
type MBRelease struct {
MBID string
Title string
Date string
Country string
Status string
ArtistCredit string
Tracks []CandidateTrack
}
// MBClient is the subset of the explore.MusicBrainzClient surface
// the autotagger depends on. Implementations must be cache-first
// — repeated calls with the same inputs must not repeat network
// round-trips.
type MBClient interface {
SearchReleaseGroups(
ctx context.Context,
query string,
limit int,
) ([]MBReleaseGroupHit, int, error)
BrowseReleases(ctx context.Context, releaseGroupMBID string) ([]MBRelease, error)
LookupRelease(ctx context.Context, releaseMBID string) (MBRelease, error)
LookupReleaseGroup(ctx context.Context, releaseGroupMBID string) (MBReleaseGroupHit, error)
LookupArtist(ctx context.Context, mbid string) (string, error) // returns sort name or name
}
// MBResolver orchestrates MusicBrainz lookups for a tagging group.
// Strategy: normalize the user-provided album/artist first, then
// issue a cascade of progressively looser Lucene queries, stopping
// at the first one that yields enough candidates.
type MBResolver struct {
client MBClient
logger *slog.Logger
limit int
}
// NewMBResolver wires up the resolver with a default search limit.
func NewMBResolver(client MBClient, logger *slog.Logger) *MBResolver {
const defaultLimit = 5
return &MBResolver{client: client, logger: logger, limit: defaultLimit}
}
// mbQueryStep describes one cascade level. `label` is surfaced in
// candidate provenance (so the UI can show "via fuzzy title").
type mbQueryStep struct {
label string
query string
}
// ResolveMB returns MB-sourced candidates for a tagging group.
// Runs a cascade of Lucene queries, returning at the first step
// that produces results. Each search hit fans out to
// BrowseReleases (one per release-group) for track-level data.
func (r *MBResolver) ResolveMB(
ctx context.Context,
albumName, albumArtist string,
trackCount int,
knownArtistMBID string,
) ([]Candidate, error) {
if albumName == "" {
return nil, nil
}
nAlbum := Normalize(albumName)
nArtist := Normalize(albumArtist)
if nAlbum == "" {
return nil, nil
}
steps := buildMBQueryCascade(nAlbum, nArtist, trackCount, knownArtistMBID)
for _, step := range steps {
hits, _, err := r.client.SearchReleaseGroups(ctx, step.query, r.limit)
if err != nil {
r.logger.Warn(
"MB search step failed — trying next",
"step", step.label, "query", step.query, "err", err,
)
continue
}
if len(hits) == 0 {
r.logger.Debug(
"MB search step empty — trying next",
"step", step.label, "query", step.query,
)
continue
}
r.logger.Info(
"MB search step succeeded",
"step", step.label, "hits", len(hits),
)
return r.fanOutBrowse(ctx, hits, step.label), nil
}
return nil, nil
}
// fanOutBrowse iterates search hits, fetches each release-group's
// releases, and returns them as Candidates. Errors on individual
// browses are logged and skipped.
func (r *MBResolver) fanOutBrowse(
ctx context.Context, hits []MBReleaseGroupHit, step string,
) []Candidate {
var out []Candidate
for _, h := range hits {
releases, err := r.client.BrowseReleases(ctx, h.MBID)
if err != nil {
r.logger.Warn(
"browse releases failed — skipping release group",
"release_group_mbid", h.MBID, "err", err,
)
continue
}
for _, rel := range releases {
out = append(out, mkCandidate(h, rel, step))
}
}
return out
}
// ResolveOneReleaseMBID fetches a single release by MBID and
// returns it as a fully-populated Candidate. Used by the paste-
// URL escape hatch. Falls back to LookupReleaseGroup when the
// MBID resolves to a release group instead.
func (r *MBResolver) ResolveOneReleaseMBID(
ctx context.Context, mbid string,
) (Candidate, error) {
rel, err := r.client.LookupRelease(ctx, mbid)
if err == nil && rel.MBID != "" {
return Candidate{
ReleaseMBID: rel.MBID,
ReleaseGroupMBID: "",
Title: rel.Title,
ArtistCredit: rel.ArtistCredit,
Date: rel.Date,
Country: rel.Country,
Status: rel.Status,
TrackCount: len(rel.Tracks),
Tracks: rel.Tracks,
Source: SourceMusicBrainz,
Provenance: "paste",
}, nil
}
rgHit, rgErr := r.client.LookupReleaseGroup(ctx, mbid)
if rgErr != nil {
return Candidate{}, fmt.Errorf("lookup release or RG: %w / %w", err, rgErr)
}
releases, bErr := r.client.BrowseReleases(ctx, rgHit.MBID)
if bErr != nil {
return Candidate{}, fmt.Errorf("browse RG %s: %w", rgHit.MBID, bErr)
}
if len(releases) == 0 {
return Candidate{}, fmt.Errorf("%w: %s", errEmptyBrowseResult, mbid)
}
return mkCandidate(rgHit, releases[0], "paste"), nil
}
// mkCandidate combines a search hit with one of its releases into
// a Candidate ready for scoring. Date is the release-specific date
// (re-issue year for remasters), OriginalDate is the release-group's
// first-release-date (the album's original year).
func mkCandidate(h MBReleaseGroupHit, rel MBRelease, step string) Candidate {
return Candidate{
ReleaseMBID: rel.MBID,
ReleaseGroupMBID: h.MBID,
Title: firstNonEmpty(rel.Title, h.Title),
ArtistCredit: firstNonEmpty(rel.ArtistCredit, h.ArtistCredit),
Date: firstNonEmpty(rel.Date, h.FirstDate),
OriginalDate: h.FirstDate,
Country: rel.Country,
Status: rel.Status,
TrackCount: len(rel.Tracks),
Tracks: rel.Tracks,
Source: SourceMusicBrainz,
Provenance: step,
}
}
// buildMBQueryCascade returns the Lucene queries to try in order.
// Cascade:
//
// 1. Full: release + arid/artist + tracks:N
// 2. Drop tracks:N (bonus tracks, live editions, etc.)
// 3. Drop artist entirely (wrong artist tag is common)
// 4. Fuzzy title (unquoted; Lucene does token/prefix match)
//
// Each step is only added when it would differ from the previous.
func buildMBQueryCascade(
normAlbum, normArtist string,
trackCount int,
artistMBID string,
) []mbQueryStep {
var steps []mbQueryStep
release := "release:" + luceneQuote(normAlbum)
artistClause := ""
switch {
case artistMBID != "":
artistClause = "arid:" + artistMBID
case normArtist != "":
artistClause = "artist:" + luceneQuote(normArtist)
}
tracksClause := ""
if trackCount > 0 {
tracksClause = fmt.Sprintf("tracks:%d", trackCount)
}
// Step 1: full query (only include parts we actually have).
steps = append(steps, mbQueryStep{
label: "strict",
query: joinNonEmpty(release, artistClause, tracksClause),
})
// Step 2: drop tracks:N if we had one.
if tracksClause != "" {
steps = append(steps, mbQueryStep{
label: "no-track-count",
query: joinNonEmpty(release, artistClause),
})
}
// Step 3: drop the artist clause.
if artistClause != "" {
steps = append(steps, mbQueryStep{
label: "title-only",
query: release,
})
}
// Step 4: fuzzy / unquoted title. MB's Lucene tokenizer will
// do prefix + fuzzy matching on bare tokens.
fuzzy := "release:" + luceneTokens(normAlbum)
if fuzzy != release {
steps = append(steps, mbQueryStep{
label: "fuzzy-title",
query: fuzzy,
})
}
return steps
}
// luceneQuote wraps a phrase in quotes and escapes embedded quotes
// and backslashes. Used for exact-phrase clauses.
func luceneQuote(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
return `"` + s + `"`
}
// luceneTokens returns the phrase as space-separated tokens with
// Lucene reserved characters escaped. MB's analyzer applies
// tokenization + fuzzy matching across bare tokens, so this is
// the right shape for our loosest cascade level.
func luceneTokens(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return s
}
// Escape Lucene-reserved glyphs that aren't stripped by our
// Normalize() (paren, bracket, colon, etc. Normalize already
// drops most of these, but belt + braces).
reserved := `+-&|!(){}[]^"~*?:\/`
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if strings.ContainsRune(reserved, r) {
b.WriteRune('\\')
}
b.WriteRune(r)
}
return b.String()
}
// joinNonEmpty joins non-empty parts with " AND ".
func joinNonEmpty(parts ...string) string {
kept := make([]string, 0, len(parts))
for _, p := range parts {
if p != "" {
kept = append(kept, p)
}
}
return strings.Join(kept, " AND ")
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
// errEmptyBrowseResult signals that LookupReleaseGroup succeeded
// but BrowseReleases returned nothing — unusual, but not fatal.
var errEmptyBrowseResult = errors.New("autotag: release group has no releases")
+193
View File
@@ -0,0 +1,193 @@
package autotag
import (
"context"
"errors"
"log/slog"
"strings"
"testing"
)
var errFakeNotFound = errors.New("fake: not found")
// fakeMBClient is a minimal stub that records the queries the
// resolver sends and returns canned results per query/MBID. The
// cascade tests orchestrate `searchByStep` so step N returns hits
// only when the resolver has already tried steps < N.
type fakeMBClient struct {
queries []string
searchByStep map[int][]MBReleaseGroupHit
browseByMBID map[string][]MBRelease
lookupRels map[string]MBRelease
lookupRGs map[string]MBReleaseGroupHit
}
func (f *fakeMBClient) SearchReleaseGroups(
_ context.Context, query string, _ int,
) ([]MBReleaseGroupHit, int, error) {
f.queries = append(f.queries, query)
step := len(f.queries) - 1
hits := f.searchByStep[step]
return hits, len(hits), nil
}
func (f *fakeMBClient) BrowseReleases(
_ context.Context, mbid string,
) ([]MBRelease, error) {
return f.browseByMBID[mbid], nil
}
func (f *fakeMBClient) LookupRelease(
_ context.Context, mbid string,
) (MBRelease, error) {
rel, ok := f.lookupRels[mbid]
if !ok {
return MBRelease{}, errFakeNotFound
}
return rel, nil
}
func (f *fakeMBClient) LookupReleaseGroup(
_ context.Context, mbid string,
) (MBReleaseGroupHit, error) {
rg, ok := f.lookupRGs[mbid]
if !ok {
return MBReleaseGroupHit{}, errFakeNotFound
}
return rg, nil
}
func (f *fakeMBClient) LookupArtist(_ context.Context, _ string) (string, error) {
return "", nil
}
func TestBuildMBQueryCascade_StepsOrder(t *testing.T) {
t.Parallel()
// Normalize() is a no-op for these inputs — ASCII, no
// qualifier suffix, no punctuation — so cascade builds directly.
steps := buildMBQueryCascade("abbey road", "the beatles", 17, "")
if len(steps) < 3 {
t.Fatalf("expected ≥3 cascade steps, got %d", len(steps))
}
if !strings.Contains(steps[0].query, "tracks:17") {
t.Errorf("step 1 should include tracks:17, got %q", steps[0].query)
}
if strings.Contains(steps[1].query, "tracks:") {
t.Errorf("step 2 should drop tracks:, got %q", steps[1].query)
}
if strings.Contains(steps[2].query, "artist:") {
t.Errorf("step 3 should drop artist:, got %q", steps[2].query)
}
}
func TestBuildMBQueryCascade_NormalizesInputs(t *testing.T) {
t.Parallel()
// Qualifier suffix "(Remastered 2009)" must be stripped by
// the *caller*; verify the query emitter doesn't reintroduce it.
steps := buildMBQueryCascade("abbey road", "", 0, "")
for _, step := range steps {
if strings.Contains(strings.ToLower(step.query), "remastered") {
t.Errorf("step %q should not contain qualifier: %q", step.label, step.query)
}
}
}
func TestMBResolver_CascadeStopsOnFirstHit(t *testing.T) {
t.Parallel()
fake := &fakeMBClient{
searchByStep: map[int][]MBReleaseGroupHit{
// step 0 (strict) returns nothing; step 1 (no-track-count) hits.
1: {{MBID: "rg1", Title: "Abbey Road"}},
},
browseByMBID: map[string][]MBRelease{
"rg1": {{MBID: "rel1", Title: "Abbey Road", Tracks: []CandidateTrack{
{Position: 1, Title: "Come Together"},
}}},
},
}
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
cands, err := r.ResolveMB(
context.Background(), "Abbey Road", "The Beatles", 17, "",
)
if err != nil {
t.Fatalf("ResolveMB: %v", err)
}
if len(cands) != 1 {
t.Fatalf("expected 1 candidate, got %d", len(cands))
}
if len(fake.queries) != 2 { //nolint:mnd
t.Errorf("expected 2 search queries (strict + no-track-count), got %d", len(fake.queries))
}
if cands[0].Provenance != "no-track-count" {
t.Errorf("provenance = %q, want 'no-track-count'", cands[0].Provenance)
}
}
func TestMBResolver_AbortsOnEmptyAlbumName(t *testing.T) {
t.Parallel()
fake := &fakeMBClient{}
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
cands, err := r.ResolveMB(context.Background(), "", "", 0, "")
if err != nil {
t.Fatalf("ResolveMB: %v", err)
}
if cands != nil {
t.Errorf("cands should be nil for empty album, got %d", len(cands))
}
if len(fake.queries) != 0 {
t.Errorf("empty album should not trigger search, got %d queries", len(fake.queries))
}
}
func TestMBResolver_ResolveOneReleaseMBID(t *testing.T) {
t.Parallel()
fake := &fakeMBClient{
lookupRels: map[string]MBRelease{
"rel-mbid": {
MBID: "rel-mbid", Title: "Abbey Road",
ArtistCredit: "The Beatles",
Tracks: []CandidateTrack{{Position: 1, Title: "Come Together"}},
},
},
}
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
cand, err := r.ResolveOneReleaseMBID(context.Background(), "rel-mbid")
if err != nil {
t.Fatalf("ResolveOneReleaseMBID: %v", err)
}
if cand.Title != "Abbey Road" {
t.Errorf("title = %q, want Abbey Road", cand.Title)
}
if cand.Provenance != "paste" {
t.Errorf("provenance = %q, want 'paste'", cand.Provenance)
}
if len(cand.Tracks) != 1 {
t.Errorf("expected 1 track, got %d", len(cand.Tracks))
}
}
+77
View File
@@ -0,0 +1,77 @@
package autotag
import (
"regexp"
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
// qualifierPattern matches common parenthesized / bracketed
// qualifiers that MB sometimes adds to titles but user tags often
// omit — e.g. `(Remastered 2009)`, `[Bonus Track]`, `(feat. X)`.
// Case-insensitive. Only strips when the qualifier is at a
// word-boundary to avoid mangling titles like "Untitled (1)".
var qualifierPattern = regexp.MustCompile(
`(?i)\s*[\(\[]\s*(` +
`remaster(ed)?(\s+\d{4})?|` +
`re-?master(ed)?(\s+\d{4})?|` +
`\d{4}\s+remaster(ed)?|` +
`deluxe(\s+edition)?|` +
`expanded(\s+edition)?|` +
`explicit|` +
`clean|` +
`bonus\s+track|` +
`live(\s+at\s+[^\)\]]*)?|` +
`acoustic|` +
`radio\s+edit|` +
`single\s+version|` +
`album\s+version|` +
`instrumental|` +
`demo|` +
`mono|` +
`stereo|` +
`feat\.?\s+[^\)\]]*|` +
`featuring\s+[^\)\]]*|` +
`ft\.?\s+[^\)\]]*` +
`)\s*[\)\]]`,
)
// whitespaceCollapse replaces runs of whitespace with a single space.
var whitespaceCollapse = regexp.MustCompile(`\s+`)
// Normalize returns a comparison-friendly form of a title or
// artist-credit string:
//
// - NFC unicode composition
// - qualifier suffixes stripped (see qualifierPattern)
// - all punctuation dropped
// - case folded (lowercased)
// - whitespace collapsed and trimmed
//
// The result is not intended to be human-readable — only used for
// equality and edit-distance comparisons inside the scorer.
func Normalize(s string) string {
if s == "" {
return ""
}
s = norm.NFC.String(s)
s = qualifierPattern.ReplaceAllString(s, "")
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
b.WriteRune(unicode.ToLower(r))
case unicode.IsSpace(r):
b.WriteRune(' ')
}
}
return strings.TrimSpace(whitespaceCollapse.ReplaceAllString(b.String(), " "))
}
+45
View File
@@ -0,0 +1,45 @@
package autotag_test
import (
"testing"
"yellowjacket/backend/autotag"
)
func TestNormalize(t *testing.T) {
t.Parallel()
cases := map[string]struct {
in string
want string
}{
"empty": {"", ""},
"already normalized": {"abbey road", "abbey road"},
"case fold": {"Abbey Road", "abbey road"},
"punctuation stripped": {
"Sgt. Pepper's Lonely Hearts Club Band!",
"sgt peppers lonely hearts club band",
},
"NFC unicode": {"Beyoncé", "beyoncé"},
"remastered qualifier": {"Abbey Road (Remastered 2009)", "abbey road"},
"remaster no year": {"Abbey Road (Remaster)", "abbey road"},
"explicit qualifier": {"Lemonade [Explicit]", "lemonade"},
"feat qualifier": {"Yellow (feat. Coldplay)", "yellow"},
"bonus track qualifier": {"Hey Jude [Bonus Track]", "hey jude"},
"collapse whitespace": {" Abbey Road ", "abbey road"},
"non-ascii digits kept": {"Track 7", "track 7"},
"numbered title not mangled": {"Untitled (1)", "untitled 1"},
"year remaster form": {"Abbey Road (2009 Remaster)", "abbey road"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
got := autotag.Normalize(tc.in)
if got != tc.want {
t.Errorf("Normalize(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
+185
View File
@@ -0,0 +1,185 @@
package autotag
import (
"sort"
"strconv"
"strings"
)
// Release-level scoring weights. Aggregate track score is the
// dominant signal — the release-level signals are tie-breakers
// when the track alignment is roughly comparable.
const (
weightTrackAggregate = 0.70
weightTrackCountMatch = 0.15
weightReleaseMeta = 0.15 // Official + country averaged
// Country preference: a very mild nudge toward releases from
// the user's locale. Will become a config option in 012.
preferredCountry = "US"
)
// ScoreCandidate fills in c.Alignments, c.Score, c.Breakdown, and
// c.TrackCount for a single candidate against the given local
// tracks. The returned Candidate is safe to copy — no shared
// state with the caller's slice.
func ScoreCandidate(local []LocalTrack, c Candidate, localTrackCount int) Candidate {
c.Alignments = AlignTracks(local, c.Tracks)
var (
titleSum float64
lengthSum float64
counted int
)
for _, a := range c.Alignments {
if a.Status != AlignmentMatched && a.Status != AlignmentMismatched {
continue
}
counted++
titleSum += a.TitleScore
l := local[a.LocalIndex]
lengthSum += lengthScore(l.LengthMillis, a.CandidateLength)
}
titleAvg, lengthAvg := 0.0, 0.0
if counted > 0 {
titleAvg = titleSum / float64(counted)
lengthAvg = lengthSum / float64(counted)
}
// Aggregate track score: weighted title + length (renormalized
// so a perfect match scales to 1.0 regardless of the absolute
// weights), scaled by how many of our local tracks actually
// matched — extra or missing tracks punish proportionally.
coverage := 0.0
if len(local) > 0 {
coverage = float64(counted) / float64(len(local))
}
const trackWeightSum = weightTitle + weightLength
trackAgg := ((titleAvg*weightTitle + lengthAvg*weightLength) / trackWeightSum) * coverage
trackCountScore := trackCountMatch(len(c.Tracks), localTrackCount)
// Release-meta is just official-status + country preference,
// averaged. We used to mix in a year bonus too, but that
// compared candidate years against time.Now() — penalising
// every album that wasn't from this year, regardless of how
// well it matched the local files. See git history.
const metaTerms = 2.0
meta := (officialBonus(c.Status) + countryBonus(c.Country)) / metaTerms
c.Score = trackAgg*weightTrackAggregate +
trackCountScore*weightTrackCountMatch +
meta*weightReleaseMeta
c.Breakdown = ScoreBreakdown{
TitleAvg: titleAvg,
LengthAvg: lengthAvg,
TrackCountFit: trackCountScore,
ReleaseMeta: meta,
}
c.TrackCount = len(c.Tracks)
return c
}
// trackCountMatch returns 1.0 when equal, 0.0 when off by >= 50%,
// linear between.
func trackCountMatch(a, b int) float64 {
if a == 0 && b == 0 {
return 1.0
}
if a == 0 || b == 0 {
return 0.0
}
diff := a - b
if diff < 0 {
diff = -diff
}
larger := a
if b > larger {
larger = b
}
frac := float64(diff) / float64(larger)
const halfwayPenalty = 0.5
if frac >= halfwayPenalty {
return 0.0
}
return 1.0 - frac/halfwayPenalty
}
// officialBonus returns 1.0 for Official releases, 0.5 for others
// (Promotion, Bootleg, ...), 0.5 when unknown.
func officialBonus(status string) float64 {
const partial = 0.5
switch strings.ToLower(status) {
case "official":
return 1.0
case "":
return partial
default:
return partial
}
}
// countryBonus gives a mild nudge toward releases from the
// preferred country. Neutral (0.5) when country is absent.
func countryBonus(country string) float64 {
const (
neutral = 0.5
hit = 1.0
)
if country == "" {
return neutral
}
if strings.EqualFold(country, preferredCountry) {
return hit
}
return neutral
}
// parseYear pulls the first 4-digit year out of date strings like
// "2009", "2009-05-18", "".
func parseYear(date string) int {
if len(date) < 4 { //nolint:mnd
return 0
}
y, err := strconv.Atoi(date[:4])
if err != nil {
return 0
}
return y
}
// RankCandidates scores each candidate against the local tracks
// and returns a new slice sorted descending by score. Input slice
// is not modified.
func RankCandidates(local []LocalTrack, candidates []Candidate) []Candidate {
scored := make([]Candidate, 0, len(candidates))
for _, c := range candidates {
scored = append(scored, ScoreCandidate(local, c, len(local)))
}
sort.SliceStable(scored, func(i, j int) bool {
return scored[i].Score > scored[j].Score
})
return scored
}
+174
View File
@@ -0,0 +1,174 @@
package autotag_test
import (
"testing"
"yellowjacket/backend/autotag"
)
func TestRankCandidates_PrefersExactTrackCountMatch(t *testing.T) {
t.Parallel()
local := []autotag.LocalTrack{
{Title: "A", TrackNumber: 1, LengthMillis: 200000},
{Title: "B", TrackNumber: 2, LengthMillis: 200000},
{Title: "C", TrackNumber: 3, LengthMillis: 200000},
}
matching := autotag.Candidate{
ReleaseMBID: "exact",
Title: "Album",
Tracks: []autotag.CandidateTrack{
{Position: 1, Title: "A", LengthMillis: 200000},
{Position: 2, Title: "B", LengthMillis: 200000},
{Position: 3, Title: "C", LengthMillis: 200000},
},
}
longer := autotag.Candidate{
ReleaseMBID: "longer",
Title: "Album",
Tracks: []autotag.CandidateTrack{
{Position: 1, Title: "A", LengthMillis: 200000},
{Position: 2, Title: "B", LengthMillis: 200000},
{Position: 3, Title: "C", LengthMillis: 200000},
{Position: 4, Title: "Bonus", LengthMillis: 200000},
{Position: 5, Title: "Extra", LengthMillis: 200000},
},
}
ranked := autotag.RankCandidates(local, []autotag.Candidate{longer, matching})
if ranked[0].ReleaseMBID != "exact" {
t.Errorf(
"top = %q (score %.2f vs %.2f), want 'exact'",
ranked[0].ReleaseMBID, ranked[0].Score, ranked[1].Score,
)
}
}
func TestRankCandidates_PrefersOfficial(t *testing.T) {
t.Parallel()
local := []autotag.LocalTrack{
{Title: "A", TrackNumber: 1, LengthMillis: 200000},
}
tracks := []autotag.CandidateTrack{
{Position: 1, Title: "A", LengthMillis: 200000},
}
official := autotag.Candidate{
ReleaseMBID: "official",
Status: "Official",
Tracks: tracks,
}
promo := autotag.Candidate{
ReleaseMBID: "promo",
Status: "Promotion",
Tracks: tracks,
}
ranked := autotag.RankCandidates(local, []autotag.Candidate{promo, official})
if ranked[0].ReleaseMBID != "official" {
t.Errorf("top = %q, want 'official'", ranked[0].ReleaseMBID)
}
}
func TestRankCandidates_MultiDisc(t *testing.T) {
t.Parallel()
// Two-disc album: local has 4 tracks across 2 discs, candidate
// matches them. Should score near-perfectly.
local := []autotag.LocalTrack{
{Title: "D1T1", DiscNumber: 1, TrackNumber: 1, LengthMillis: 200000},
{Title: "D1T2", DiscNumber: 1, TrackNumber: 2, LengthMillis: 200000},
{Title: "D2T1", DiscNumber: 2, TrackNumber: 1, LengthMillis: 200000},
{Title: "D2T2", DiscNumber: 2, TrackNumber: 2, LengthMillis: 200000},
}
cand := autotag.Candidate{
ReleaseMBID: "multidisc",
Status: "Official",
Tracks: []autotag.CandidateTrack{
{Position: 1, DiscNumber: 1, Title: "D1T1", LengthMillis: 200000},
{Position: 2, DiscNumber: 1, Title: "D1T2", LengthMillis: 200000},
{Position: 1, DiscNumber: 2, Title: "D2T1", LengthMillis: 200000},
{Position: 2, DiscNumber: 2, Title: "D2T2", LengthMillis: 200000},
},
}
ranked := autotag.RankCandidates(local, []autotag.Candidate{cand})
if ranked[0].Score < 0.75 { //nolint:mnd
t.Errorf("multi-disc exact match = %.2f, want >= 0.75", ranked[0].Score)
}
}
func TestRankCandidates_VariousArtists(t *testing.T) {
t.Parallel()
// Compilation: every track has a different artist in the tag.
// Scoring should still produce a high score on title + length
// even though artist credits differ.
local := []autotag.LocalTrack{
{Title: "Song 1", Artist: "Artist One", TrackNumber: 1, LengthMillis: 200000},
{Title: "Song 2", Artist: "Artist Two", TrackNumber: 2, LengthMillis: 180000},
{Title: "Song 3", Artist: "Artist Three", TrackNumber: 3, LengthMillis: 220000},
}
cand := autotag.Candidate{
ReleaseMBID: "va-comp",
Title: "Various: Great Hits",
Status: "Official",
Tracks: []autotag.CandidateTrack{
{Position: 1, Title: "Song 1", LengthMillis: 200000},
{Position: 2, Title: "Song 2", LengthMillis: 180000},
{Position: 3, Title: "Song 3", LengthMillis: 220000},
},
}
ranked := autotag.RankCandidates(local, []autotag.Candidate{cand})
if ranked[0].Score < 0.75 { //nolint:mnd
t.Errorf("VA-compilation exact track match = %.2f, want >= 0.75", ranked[0].Score)
}
}
func TestRankCandidates_AmbiguousAlbumNames(t *testing.T) {
t.Parallel()
// Two candidates with the same album title ("Greatest Hits")
// but one has matching tracks and one doesn't. The track
// alignment should pick the right one.
local := []autotag.LocalTrack{
{Title: "Hotel California", TrackNumber: 1, LengthMillis: 391000},
{Title: "Take It Easy", TrackNumber: 2, LengthMillis: 213000},
}
eagles := autotag.Candidate{
ReleaseMBID: "eagles-gh",
Title: "Greatest Hits",
ArtistCredit: "Eagles",
Tracks: []autotag.CandidateTrack{
{Position: 1, Title: "Hotel California", LengthMillis: 391000},
{Position: 2, Title: "Take It Easy", LengthMillis: 213000},
},
}
queen := autotag.Candidate{
ReleaseMBID: "queen-gh",
Title: "Greatest Hits",
ArtistCredit: "Queen",
Tracks: []autotag.CandidateTrack{
{Position: 1, Title: "Bohemian Rhapsody", LengthMillis: 354000},
{Position: 2, Title: "We Will Rock You", LengthMillis: 121000},
},
}
ranked := autotag.RankCandidates(local, []autotag.Candidate{queen, eagles})
if ranked[0].ReleaseMBID != "eagles-gh" {
t.Errorf(
"top = %q (%.2f vs %.2f), want 'eagles-gh'",
ranked[0].ReleaseMBID, ranked[0].Score, ranked[1].Score,
)
}
}
+174
View File
@@ -0,0 +1,174 @@
package autotag
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"yellowjacket/backend/database/sql/sqlcgen"
)
// ErrGroupNotFound is returned when ScoreGroup is asked to score
// a group_key that isn't in tagging_items.
var ErrGroupNotFound = errors.New("autotag: tagging group not found")
// Scorer ties the local resolver and the optional MB resolver
// together, producing ranked candidates for a tagging group and
// optionally persisting the top pick back to tagging_items.
type Scorer struct {
q *sqlcgen.Queries
local *LocalResolver
mb *MBResolver // may be nil for dry-run / offline scoring
log *slog.Logger
}
// NewScorer wires up a Scorer. Pass mb=nil to disable MB calls
// entirely (useful for tuning / tests).
func NewScorer(q *sqlcgen.Queries, mb MBClient, logger *slog.Logger) *Scorer {
var resolver *MBResolver
if mb != nil {
resolver = NewMBResolver(mb, logger)
}
return &Scorer{
q: q,
local: NewLocalResolver(q),
mb: resolver,
log: logger,
}
}
// ScoreGroup produces the full GroupScore for one tagging item.
// Always runs both the local resolver (free) and the MB resolver
// (cache-first, so repeats cost nothing). Candidates from both
// sources are merged and ranked — the UI displays them with
// provenance badges so the user can compare.
func (s *Scorer) ScoreGroup(
ctx context.Context, groupKey string,
) (*GroupScore, error) {
item, err := s.q.GetTaggingItem(ctx, groupKey)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrGroupNotFound
}
return nil, fmt.Errorf("get tagging item: %w", err)
}
locals, err := s.local.LocalTracksForGroup(ctx, groupKey)
if err != nil {
return nil, err
}
localHits, err := s.local.ResolveLocal(ctx, item.AlbumName)
if err != nil {
return nil, err
}
var mbHits []Candidate
if s.mb != nil {
mbHits, err = s.mb.ResolveMB(
ctx,
item.AlbumName,
item.AlbumArtist,
len(locals),
guessArtistMBID(locals),
)
if err != nil {
s.log.Warn(
"MB resolve failed — returning local-only candidates",
"group_key", groupKey,
"err", err,
)
}
}
candidates := RankCandidates(locals, append(localHits, mbHits...))
return &GroupScore{
GroupKey: groupKey,
LocalTracks: locals,
Candidates: candidates,
}, nil
}
// LocalTracksForGroup exposes the local resolver so callers that
// already have a candidate list (e.g. from the service-layer
// candidate cache) can build a GroupScore without re-running the
// whole scorer.
func (s *Scorer) LocalTracksForGroup(
ctx context.Context, groupKey string,
) ([]LocalTrack, error) {
return s.local.LocalTracksForGroup(ctx, groupKey)
}
// PersistBest writes the top candidate's release MBID and score
// onto the tagging_items row, bumping status to 'matched' when a
// candidate exists. No-op when candidates is empty (keeps the
// current status — likely 'pending').
func (s *Scorer) PersistBest(
ctx context.Context, score *GroupScore,
) error {
if score == nil || len(score.Candidates) == 0 {
return nil
}
top := score.Candidates[0]
mbid := top.ReleaseMBID
if mbid == "" {
mbid = top.ReleaseGroupMBID
}
return s.q.SetTaggingItemBestMatch(ctx, sqlcgen.SetTaggingItemBestMatchParams{
BestMatchReleaseMbid: sql.NullString{String: mbid, Valid: mbid != ""},
Score: sql.NullFloat64{Float64: top.Score, Valid: true},
Status: "matched",
GroupKey: score.GroupKey,
})
}
// PersistScore writes the top candidate's release MBID and score
// onto the tagging_items row WITHOUT touching its status. Use
// this from paths that want the sidebar pill / sort to reflect a
// fresh score but must leave 'pending' folders pending — namely
// the live re-score on folder open and the background prefetch
// worker. No-op when candidates is empty.
func (s *Scorer) PersistScore(
ctx context.Context, score *GroupScore,
) error {
if score == nil || len(score.Candidates) == 0 {
return nil
}
top := score.Candidates[0]
mbid := top.ReleaseMBID
if mbid == "" {
mbid = top.ReleaseGroupMBID
}
return s.q.SetTaggingItemScore(ctx, sqlcgen.SetTaggingItemScoreParams{
BestMatchReleaseMbid: sql.NullString{String: mbid, Valid: mbid != ""},
Score: sql.NullFloat64{Float64: top.Score, Valid: true},
GroupKey: score.GroupKey,
})
}
// guessArtistMBID returns the first non-empty recording MBID-
// derived artist hint we can find. Tracks carry recording MBIDs,
// not artist MBIDs, but existing partial tags are often consistent
// enough that any non-empty MBID signals "this album already has
// some MB lineage". A follow-up in 010 can resolve actual artist
// MBIDs via the artists table.
func guessArtistMBID(tracks []LocalTrack) string {
// Phase 009 doesn't wire up per-track artist MBIDs through
// the LocalTrack struct yet — keeping the hook so the MB
// resolver still compiles with the empty hint. Real artist
// MBIDs flow in once 010 wires them into LocalTrack.
_ = tracks
return ""
}
+280
View File
@@ -0,0 +1,280 @@
package autotag_test
import (
"context"
"database/sql"
"log/slog"
"testing"
"yellowjacket/backend/autotag"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
)
// seedAlbum drops a minimal release_group + recordings + audio_files
// chain into the test DB, returning the group_key derived by the
// scan pipeline.
type seededAlbum struct {
groupKey string
albumName string
libraryID int64
releaseMBID string // MBID set on release_groups row (empty → none)
tracks []seededTrack
}
type seededTrack struct {
filePath string
title string
trackNumber int
lengthMillis int64
recordingMBID string // set a recording MBID to mark this track as "already tagged"
}
func seed(t *testing.T, db *database.DB, album seededAlbum) {
t.Helper()
ctx := db.Ctx
q := db.Queries
ac, err := q.UpsertArtistCredit(ctx, "Test Artist")
if err != nil {
t.Fatalf("upsert ac: %v", err)
}
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
Name: album.albumName,
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
})
if err != nil {
t.Fatalf("upsert rg: %v", err)
}
if album.releaseMBID != "" {
if _, err := db.ExecContext(
`UPDATE release_groups SET mbid = ? WHERE id = ?`,
album.releaseMBID, rg.ID,
); err != nil {
t.Fatalf("set rg mbid: %v", err)
}
}
for _, tr := range album.tracks {
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
Name: tr.title,
ArtistCreditID: ac.ID,
TrackNumber: sql.NullInt64{Int64: int64(tr.trackNumber), Valid: true},
})
if err != nil {
t.Fatalf("create recording: %v", err)
}
if tr.recordingMBID != "" {
if _, err := db.ExecContext(
`UPDATE recordings SET mbid = ? WHERE id = ?`,
tr.recordingMBID, rec.ID,
); err != nil {
t.Fatalf("set recording mbid: %v", err)
}
}
if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{
ReleaseGroupID: rg.ID,
RecordingID: rec.ID,
TrackNumber: sql.NullInt64{Int64: int64(tr.trackNumber), Valid: true},
}); err != nil {
t.Fatalf("link rg recording: %v", err)
}
if _, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
FilePath: tr.filePath,
LengthMilliseconds: tr.lengthMillis,
FileTypeID: 0,
RecordingID: rec.ID,
Basename: tr.filePath,
LibraryID: album.libraryID,
GroupKey: album.groupKey,
TagStatus: "untagged",
}); err != nil {
t.Fatalf("create audio file: %v", err)
}
}
if _, err := db.ExecContext(`
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
VALUES (?, ?, ?, ?, ?, 0, 'pending')
`, album.groupKey, album.libraryID, len(album.tracks), album.albumName, "Test Artist"); err != nil {
t.Fatalf("insert tagging item: %v", err)
}
}
func TestScorer_LocalHitSurfacesFirst(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
// Seed a local release_group WITH an MBID — this is the
// zero-cost candidate. Canonical tracks carry recording MBIDs
// so the local resolver treats them as tagged candidates.
seed(t, db, seededAlbum{
groupKey: "g-canonical",
albumName: "Good Album",
libraryID: 0,
releaseMBID: "rg-abcd",
tracks: []seededTrack{
{
filePath: "/lib/a.mp3", title: "Song A",
trackNumber: 1, lengthMillis: 200000,
recordingMBID: "rec-a",
},
{
filePath: "/lib/b.mp3", title: "Song B",
trackNumber: 2, lengthMillis: 180000,
recordingMBID: "rec-b",
},
},
})
// Seed a pending group whose album name matches the canonical.
// Local resolver finds canonical as a candidate; MB would also
// run in parallel (we no longer short-circuit it). The fake
// returns no MB hits here so local is the only ranked option.
seed(t, db, seededAlbum{
groupKey: "g-pending",
albumName: "Good Album",
libraryID: 0,
tracks: []seededTrack{
{filePath: "/other/a.mp3", title: "Song A", trackNumber: 1, lengthMillis: 200000},
{filePath: "/other/b.mp3", title: "Song B", trackNumber: 2, lengthMillis: 180000},
},
})
fakeMB := &countingMBClient{onSearch: func() {}}
scorer := autotag.NewScorer(db.Queries, fakeMB, slog.New(slog.DiscardHandler))
result, err := scorer.ScoreGroup(context.Background(), "g-pending")
if err != nil {
t.Fatalf("ScoreGroup: %v", err)
}
if len(result.Candidates) == 0 {
t.Fatal("no candidates")
}
if result.Candidates[0].ReleaseGroupMBID != "rg-abcd" {
t.Errorf("top candidate mbid = %q, want 'rg-abcd'", result.Candidates[0].ReleaseGroupMBID)
}
}
func TestScorer_PersistBestWritesMatched(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seed(t, db, seededAlbum{
groupKey: "g-canonical",
albumName: "Good Album",
releaseMBID: "rg-abcd",
tracks: []seededTrack{
{
filePath: "/lib/a.mp3", title: "Song A",
trackNumber: 1, lengthMillis: 200000,
recordingMBID: "rec-a",
},
},
})
seed(t, db, seededAlbum{
groupKey: "g-pending",
albumName: "Good Album",
tracks: []seededTrack{
{filePath: "/other/a.mp3", title: "Song A", trackNumber: 1, lengthMillis: 200000},
},
})
scorer := autotag.NewScorer(
db.Queries, nil, slog.New(slog.DiscardHandler),
)
result, err := scorer.ScoreGroup(context.Background(), "g-pending")
if err != nil {
t.Fatalf("ScoreGroup: %v", err)
}
if err := scorer.PersistBest(context.Background(), result); err != nil {
t.Fatalf("PersistBest: %v", err)
}
got, err := db.Queries.GetTaggingItem(context.Background(), "g-pending")
if err != nil {
t.Fatalf("reload: %v", err)
}
if got.Status != "matched" {
t.Errorf("status = %q, want 'matched'", got.Status)
}
if !got.BestMatchReleaseMbid.Valid || got.BestMatchReleaseMbid.String != "rg-abcd" {
t.Errorf("best_match_release_mbid = %+v, want rg-abcd", got.BestMatchReleaseMbid)
}
if !got.Score.Valid || got.Score.Float64 < 0.9 { //nolint:mnd
t.Errorf("score = %+v, want >= 0.9", got.Score)
}
}
func TestScorer_GroupNotFound(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
scorer := autotag.NewScorer(db.Queries, nil, slog.New(slog.DiscardHandler))
_, err := scorer.ScoreGroup(context.Background(), "nonexistent")
if err == nil || err.Error() != autotag.ErrGroupNotFound.Error() {
t.Fatalf("err = %v, want ErrGroupNotFound", err)
}
}
// countingMBClient is a fake that increments a counter whenever
// the scorer would have made an MB call. Used to assert the
// zero-network-call path stays zero.
type countingMBClient struct {
onSearch func()
}
func (c *countingMBClient) SearchReleaseGroups(
_ context.Context, _ string, _ int,
) ([]autotag.MBReleaseGroupHit, int, error) {
c.onSearch()
return nil, 0, nil
}
func (c *countingMBClient) BrowseReleases(
_ context.Context, _ string,
) ([]autotag.MBRelease, error) {
c.onSearch()
return nil, nil
}
func (c *countingMBClient) LookupArtist(_ context.Context, _ string) (string, error) {
c.onSearch()
return "", nil
}
func (c *countingMBClient) LookupRelease(_ context.Context, _ string) (autotag.MBRelease, error) {
c.onSearch()
return autotag.MBRelease{}, nil
}
func (c *countingMBClient) LookupReleaseGroup(
_ context.Context,
_ string,
) (autotag.MBReleaseGroupHit, error) {
c.onSearch()
return autotag.MBReleaseGroupHit{}, nil
}
+110
View File
@@ -0,0 +1,110 @@
package autotag
// LocalTrack is an audio_files row projected into the shape the
// scorer cares about. All durations are in milliseconds to match
// what the MB client returns.
type LocalTrack struct {
AudioFileID int64
FilePath string
Title string
Artist string
TrackNumber int
DiscNumber int
LengthMillis int64
RecordingMBID string
}
// CandidateSource distinguishes candidates served from the local
// release_groups cache (zero network cost) from those fetched live.
type CandidateSource string
// CandidateSource values.
const (
SourceLocal CandidateSource = "local"
SourceMusicBrainz CandidateSource = "musicbrainz"
)
// Candidate is a single release that could match an album-group.
// Track alignments are produced by the scorer and carry the per-
// field diff data the review UI renders.
//
// Date is this *specific release's* date (e.g. 2010 for a remaster);
// OriginalDate is the *release group's* first-release-date (the
// original album year, e.g. 1973), populated from MB's
// release-group.first-release-date and equal to Date when the
// release-group has only one release.
type Candidate struct {
ReleaseMBID string
ReleaseGroupMBID string
Title string
ArtistCredit string
Date string // "YYYY" or "YYYY-MM-DD" — this release
OriginalDate string // "YYYY" or "YYYY-MM-DD" — release group's first release
Country string
Status string // "Official", "Promotion", ...
TrackCount int
Tracks []CandidateTrack
Alignments []TrackAlignment
Score float64 // 0..1, higher is better
Breakdown ScoreBreakdown
Source CandidateSource
Provenance string // cascade step that produced this ("strict", "fuzzy-title", "paste", "local")
}
// ScoreBreakdown exposes the four inputs that go into Candidate.Score
// so the review UI can explain the ranking to the user.
type ScoreBreakdown struct {
TitleAvg float64 // average per-track title similarity (0..1)
LengthAvg float64 // average per-track length similarity (0..1)
TrackCountFit float64 // 1.0 when local and candidate track counts match
ReleaseMeta float64 // year + official + country, averaged
}
// CandidateTrack is one track inside a candidate release.
type CandidateTrack struct {
Position int
DiscNumber int
Title string
LengthMillis int64
MBID string
}
// AlignmentStatus classifies what happened when aligning one local
// track to the best-matching candidate track.
type AlignmentStatus string
// AlignmentStatus values.
const (
AlignmentMatched AlignmentStatus = "matched"
AlignmentMissing AlignmentStatus = "missing" // candidate has the track, folder doesn't
AlignmentUnmatched AlignmentStatus = "unmatched" // folder has the track, candidate doesn't
AlignmentMismatched AlignmentStatus = "mismatched" // aligned but low confidence
)
// TrackAlignment carries the per-track diff data for one local-
// track ↔ candidate-track pairing. LocalIndex is -1 when the
// candidate track has no local file (missing); CandidatePosition
// is 0 when the local track has no match (unmatched).
type TrackAlignment struct {
LocalIndex int // index into LocalTracks, or -1
LocalTitle string
LocalLengthMillis int64
CandidatePosition int
CandidateDiscNumber int
CandidateTitle string
CandidateMBID string
CandidateLength int64
TitleScore float64 // 0..1 from normalized-title edit distance
LengthDeltaMs int64 // abs(local - candidate); 0 if either side missing
TrackNumberOK bool // local track number matches candidate position
Status AlignmentStatus
}
// GroupScore is the scorer's full output for one tagging group.
type GroupScore struct {
GroupKey string
LocalTracks []LocalTrack
Candidates []Candidate // sorted by Score, descending
}
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -21,6 +21,12 @@ import (
"yellowjacket/backend/tracklist"
)
// errSaveBeforeLoad is returned by Save when the in-memory config
// hasn't been hydrated from disk yet. Prevents writing a default-
// only struct over a real config file during abnormal lifecycle
// sequences (failed startup, racing shutdown).
var errSaveBeforeLoad = errors.New("refusing to save: config not loaded from disk")
// Config represents the application configuration.
type Config struct {
ctx context.Context
@@ -155,7 +161,7 @@ func (c *Config) Save() error {
if !c.loaded {
// Allow the initial save when the file doesn't exist yet.
if _, err := os.Stat(c.filePath); err == nil {
return fmt.Errorf("refusing to save: config not loaded from disk")
return errSaveBeforeLoad
}
}
+550 -6
View File
@@ -18,6 +18,7 @@ import (
"github.com/BurntSushi/toml"
_ "modernc.org/sqlite" // Register sqlite driver.
"yellowjacket/backend/autotag"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/profiling"
"yellowjacket/backend/system"
@@ -717,7 +718,9 @@ func runMigrations(
}
if version < 27 { //nolint:mnd
logger.Info("applying migration 27: split explore_cache into http_cache and artist_metadata")
logger.Info(
"applying migration 27: split explore_cache into http_cache and artist_metadata",
)
// Create the new tables (no-op if schemas/*.sql already created them).
if _, err := db.ExecContext(ctx, `
@@ -810,7 +813,9 @@ func runMigrations(
}
if version < 28 { //nolint:mnd
logger.Info("applying migration 28: repair broken similar_artist_map data from multi-seed labs bug")
logger.Info(
"applying migration 28: repair broken similar_artist_map data from multi-seed labs bug",
)
// The multi-seed POST form of the labs similar-artists endpoint
// returns mis-grouped results — each seed ends up with a random
@@ -830,7 +835,11 @@ func runMigrations(
"DELETE FROM explore_index_meta WHERE key = 'tier4_built'",
); err != nil {
// Not fatal — the meta table might not exist yet.
logger.Warn("migration 28: clear tier4_built failed (ok on fresh install)", "error", err)
logger.Warn(
"migration 28: clear tier4_built failed (ok on fresh install)",
"error",
err,
)
}
if _, err := db.ExecContext(ctx,
@@ -849,7 +858,9 @@ func runMigrations(
}
if version < 29 { //nolint:mnd
logger.Info("applying migration 29: discog_fetched column to track full indexer pipeline coverage")
logger.Info(
"applying migration 29: discog_fetched column to track full indexer pipeline coverage",
)
// Add a discog_fetched column to explore_index. When set to 1
// on an artist row, the indexer's fetchTopRecordings/
@@ -868,7 +879,11 @@ func runMigrations(
`); err != nil {
// May fail if migration runs against a fresh schema (column
// will be created by the schema file instead). Don't bail.
logger.Warn("migration 29: add discog_fetched column failed (ok if fresh)", "error", err)
logger.Warn(
"migration 29: add discog_fetched column failed (ok if fresh)",
"error",
err,
)
}
// Backfill: any artist with at least 5 recordings was almost
@@ -899,7 +914,9 @@ func runMigrations(
}
if version < 30 { //nolint:mnd
logger.Info("applying migration 30: invalidate MB browse-releases cache for recording MBID fix")
logger.Info(
"applying migration 30: invalidate MB browse-releases cache for recording MBID fix",
)
// Earlier versions of convertRelease used the MusicBrainz
// track MBID instead of the recording MBID for MBTrack.MBID.
@@ -923,8 +940,76 @@ func runMigrations(
}
}
if version < 31 { //nolint:mnd
if err := migration31TagStatus(ctx, db, logger); err != nil {
return err
}
}
if version < 32 { //nolint:mnd
if err := migration32TaggingItems(ctx, db, logger); err != nil {
return err
}
}
if version < 33 { //nolint:mnd
if err := migration33AutotagWarning(ctx, db, logger); err != nil {
return err
}
}
if version < 34 { //nolint:mnd
if err := migration34FolderBasedGroupKey(ctx, db, logger); err != nil {
return err
}
}
if version < 35 { //nolint:mnd
if err := migration35OriginalYear(ctx, db, logger); err != nil {
return err
}
}
if version < 36 { //nolint:mnd
if err := migration36ClearedAt(ctx, db, logger); err != nil {
return err
}
}
return nil
}
// migration36ClearedAt adds tagging_items.cleared_at — a nullable
// timestamp set when the user invokes "clear completed entries".
// Cleared rows stay in the table (so a re-scan doesn't resurrect
// them as pending) but get filtered from the review queue.
func migration36ClearedAt(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 36: tagging_items.cleared_at")
if _, err := db.ExecContext(
ctx,
`ALTER TABLE tagging_items ADD COLUMN cleared_at DATETIME`,
); err != nil {
if !strings.Contains(err.Error(), "duplicate column name") {
return fmt.Errorf("migration 36: add cleared_at: %w", err)
}
logger.Warn("migration 36: cleared_at already present (ok if fresh)", "err", err)
}
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 36"); err != nil {
return fmt.Errorf("migration 36: set user_version: %w", err)
}
logger.Info("migration 36 complete")
return nil
}
// backfills it from file_path, creates the basename index, and
// populates the FTS5 search_index table.
func migration2BasenameAndFTS(
@@ -2749,3 +2834,462 @@ func migration20TrackRecordingMBID(
return nil
}
// migration31TagStatus adds the tag_status column to audio_files,
// indexes the "untagged" slice for the pending-count badge, and
// backfills rows whose recording already carries an MBID as
// `user_confirmed`. Everything else stays at the `untagged`
// default. The column-level CHECK constraint is added inline with
// the ALTER TABLE — SQLite supports column constraints in ADD
// COLUMN, so existing DBs pick it up too.
func migration31TagStatus(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 31: tag_status column")
if _, err := db.ExecContext(ctx, `
ALTER TABLE audio_files
ADD COLUMN tag_status TEXT NOT NULL DEFAULT 'untagged'
CHECK(tag_status IN (
'untagged', 'auto_matched', 'user_confirmed', 'user_skipped_permanent'
))
`); err != nil && !isDuplicateColumnErr(err) {
return fmt.Errorf("migration 31: add tag_status: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_audio_files_tag_status_untagged
ON audio_files(library_id) WHERE tag_status = 'untagged'
`); err != nil {
return fmt.Errorf("migration 31: create index: %w", err)
}
if _, err := db.ExecContext(ctx, `
UPDATE audio_files
SET tag_status = 'user_confirmed'
WHERE tag_status = 'untagged'
AND recording_id IN (
SELECT id FROM recordings
WHERE mbid IS NOT NULL AND mbid != ''
)
`); err != nil {
return fmt.Errorf("migration 31: backfill tag_status: %w", err)
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 31",
); err != nil {
return fmt.Errorf("migration 31: set user_version: %w", err)
}
logger.Info("migration 31 complete")
return nil
}
// migration32TaggingItems creates the tagging_items table and adds
// the group_key column to audio_files, then backfills both from the
// current `audio_files` / `recordings` / `release_groups` state.
// The Go-side autotag.GroupKey helper is the single source of truth
// for the key format (keeps the hash algorithm decoupled from SQL).
//
// SAFETY: Hand-crafted ALTER TABLE + CREATE TABLE + streaming
// backfill inside a single transaction.
func migration32TaggingItems(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 32: tagging_items + group_key")
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS tagging_items (
group_key TEXT PRIMARY KEY,
library_id INTEGER NOT NULL,
track_count INTEGER NOT NULL DEFAULT 0,
album_name TEXT NOT NULL DEFAULT '',
album_artist TEXT NOT NULL DEFAULT '',
disc_number INTEGER NOT NULL DEFAULT 0,
best_match_release_mbid TEXT,
score REAL,
last_checked_at DATETIME,
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'matched', 'confirmed', 'skipped')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(library_id) REFERENCES libraries(id)
)
`); err != nil {
return fmt.Errorf("migration 32: create tagging_items: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_tagging_items_library_status
ON tagging_items(library_id, status)
`); err != nil {
return fmt.Errorf("migration 32: create library_status index: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_tagging_items_status_pending
ON tagging_items(library_id) WHERE status = 'pending'
`); err != nil {
return fmt.Errorf("migration 32: create pending index: %w", err)
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE audio_files
ADD COLUMN group_key TEXT NOT NULL DEFAULT ''
`); err != nil && !isDuplicateColumnErr(err) {
return fmt.Errorf("migration 32: add group_key: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_audio_files_group_key
ON audio_files(group_key) WHERE group_key != ''
`); err != nil {
return fmt.Errorf("migration 32: create group_key index: %w", err)
}
if err := backfillGroupKeys(ctx, db, logger); err != nil {
return fmt.Errorf("migration 32: backfill group_key: %w", err)
}
if err := aggregateTaggingItems(ctx, db, logger); err != nil {
return fmt.Errorf("migration 32: aggregate tagging_items: %w", err)
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 32",
); err != nil {
return fmt.Errorf("migration 32: set user_version: %w", err)
}
logger.Info("migration 32 complete")
return nil
}
// backfillGroupKeys streams existing audio_files rows in batches of
// ~500 and writes the computed group_key back via a single UPDATE
// per row inside one transaction. It joins to release_groups for
// the album name and recordings for the disc number; both fall back
// to the zero value when absent.
func backfillGroupKeys(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
const batchSize = 500
type row struct {
id int64
libraryID int64
filePath string
discNumber int64
}
for {
// Each pass reads the next N rows with group_key still empty;
// once updated, they drop out of the filter, so no OFFSET
// bookkeeping is needed.
rows, err := db.QueryContext(ctx, `
SELECT af.id, af.library_id, af.file_path,
COALESCE(r.disc_number, 0)
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
WHERE af.group_key = ''
ORDER BY af.id
LIMIT ?
`, batchSize)
if err != nil {
return fmt.Errorf("select batch: %w", err)
}
batch := make([]row, 0, batchSize)
for rows.Next() {
var r row
if scanErr := rows.Scan(
&r.id, &r.libraryID, &r.filePath, &r.discNumber,
); scanErr != nil {
_ = rows.Close()
return fmt.Errorf("scan row: %w", scanErr)
}
batch = append(batch, r)
}
if closeErr := rows.Close(); closeErr != nil {
return fmt.Errorf("close rows: %w", closeErr)
}
if len(batch) == 0 {
break
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
for _, r := range batch {
key := autotag.GroupKey(
r.libraryID, r.filePath, int(r.discNumber),
)
if _, err := tx.ExecContext(ctx,
`UPDATE audio_files SET group_key = ? WHERE id = ?`,
key, r.id,
); err != nil {
_ = tx.Rollback()
return fmt.Errorf("update row %d: %w", r.id, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
logger.Debug(
"migration 32: backfilled group_key batch", "count", len(batch),
)
if len(batch) < batchSize {
break
}
}
return nil
}
// aggregateTaggingItems populates tagging_items from the now-
// populated audio_files.group_key, one row per (group_key,
// library_id) pair. Status defaults to `confirmed` when every
// track in the group already has tag_status `user_confirmed`,
// otherwise `pending`.
func aggregateTaggingItems(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
result, err := db.ExecContext(ctx, `
INSERT INTO tagging_items (
group_key, library_id, track_count,
album_name, album_artist, disc_number, status
)
SELECT
af.group_key,
af.library_id,
COUNT(*) AS track_count,
COALESCE(MAX(rg.name), '') AS album_name,
COALESCE(MAX(ac.text), '') AS album_artist,
COALESCE(MAX(r.disc_number), 0) AS disc_number,
CASE WHEN SUM(CASE WHEN af.tag_status = 'user_confirmed' THEN 0 ELSE 1 END) = 0
THEN 'confirmed' ELSE 'pending' END AS status
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
WHERE af.group_key != ''
GROUP BY af.group_key, af.library_id
ON CONFLICT(group_key) DO NOTHING
`)
if err != nil {
return fmt.Errorf("insert aggregates: %w", err)
}
if n, rowsErr := result.RowsAffected(); rowsErr == nil {
logger.Debug(
"migration 32: aggregated tagging_items rows",
"count", n,
)
}
return nil
}
// migration33AutotagWarning adds the per-library flag that records
// whether the user has seen (and dismissed) the first-time autotag
// apply warning. Zero means "still warn"; one means acknowledged.
func migration33AutotagWarning(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 33: libraries.autotag_warning_acked")
if _, err := db.ExecContext(ctx, `
ALTER TABLE libraries
ADD COLUMN autotag_warning_acked INTEGER NOT NULL DEFAULT 0
`); err != nil && !isDuplicateColumnErr(err) {
return fmt.Errorf("migration 33: add column: %w", err)
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 33",
); err != nil {
return fmt.Errorf("migration 33: set user_version: %w", err)
}
logger.Info("migration 33 complete")
return nil
}
// migration35OriginalYear adds release_groups.original_year (the
// release-group's MusicBrainz first-release-date year) and rebuilds
// the track_metadata view so its "year" column prefers the original
// release year over the file-tag year. This makes a 1973 album
// show as 1973 in the tracklist and smart-playlist year rules even
// when the user owns the 2010 remaster. release_year is added as
// a separate view column for callers that need the file-tag year.
func migration35OriginalYear(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 35: release_groups.original_year + view rebuild")
if _, err := db.ExecContext(
ctx,
`ALTER TABLE release_groups ADD COLUMN original_year INTEGER`,
); err != nil {
// Tolerate duplicate-column on re-run / fresh-DB schema race.
if !strings.Contains(err.Error(), "duplicate column name") {
return fmt.Errorf("migration 35: add original_year: %w", err)
}
logger.Warn("migration 35: original_year already present (ok if fresh)", "err", err)
}
// Drop and recreate the track_metadata view so its year column
// picks up the new fallback chain. CREATE VIEW IF NOT EXISTS
// in the schema file is a no-op once the view exists, so we have
// to do this explicitly here for existing DBs.
//
// The body must match sql/schemas/track_metadata_view.sql.
if _, err := db.ExecContext(ctx, `DROP VIEW IF EXISTS track_metadata`); err != nil {
return fmt.Errorf("migration 35: drop view: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE VIEW track_metadata AS
SELECT
af.id,
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(rg.original_year, rg.year, r.year, 0) AS year,
COALESCE(rg.year, r.year, 0) AS release_year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size,
af.library_id,
af.play_count,
af.last_played,
COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id,
MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
`); err != nil {
return fmt.Errorf("migration 35: recreate view: %w", err)
}
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 35"); err != nil {
return fmt.Errorf("migration 35: set user_version: %w", err)
}
logger.Info("migration 35 complete")
return nil
}
// migration34FolderBasedGroupKey recomputes every audio_files
// row's group_key with the new folder-based algorithm (album tag
// dropped from the hash inputs). Tracks in the same parent
// directory + same disc number now share a key regardless of any
// per-track variation in their album tag — fixes the fragmenting
// behaviour where one album would produce N one-track tagging
// groups when its tracks carried slightly different album strings.
//
// After the recompute, tagging_items is wiped and re-aggregated
// from the new keys. The user's review state is reset; this is a
// blunt instrument but the right one — a partial migration would
// leave fragments of the old shape stranded in pending status.
//
// SAFETY: clears tagging_items unconditionally on existing DBs.
// On fresh DBs (test_data) the tagging_items aggregate at the end
// of the migration just no-ops since audio_files is empty.
func migration34FolderBasedGroupKey(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 34: folder-based group_key")
// Wipe stale state first so the recompute can stream into a
// clean tagging_items table.
if _, err := db.ExecContext(ctx, `DELETE FROM tagging_items`); err != nil {
return fmt.Errorf("migration 34: clear tagging_items: %w", err)
}
// Force every audio_files.group_key back to '' so the existing
// backfill logic (which filters WHERE group_key = '') can
// recompute every row.
if _, err := db.ExecContext(ctx,
`UPDATE audio_files SET group_key = ''`,
); err != nil {
return fmt.Errorf("migration 34: clear group_keys: %w", err)
}
if err := backfillGroupKeys(ctx, db, logger); err != nil {
return fmt.Errorf("migration 34: backfill: %w", err)
}
if err := aggregateTaggingItems(ctx, db, logger); err != nil {
return fmt.Errorf("migration 34: aggregate: %w", err)
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 34",
); err != nil {
return fmt.Errorf("migration 34: set user_version: %w", err)
}
logger.Info("migration 34 complete")
return nil
}
+6
View File
@@ -1079,11 +1079,13 @@ func TestMigration11ExploreCache(t *testing.T) {
if !verRows.Next() {
_ = verRows.Close()
t.Fatal("PRAGMA user_version: no row returned")
}
if err := verRows.Scan(&version); err != nil {
_ = verRows.Close()
t.Fatalf("scan user_version: %v", err)
}
@@ -1111,6 +1113,7 @@ func TestMigration11ExploreCache(t *testing.T) {
if err := tblRows.Scan(&tableCount); err != nil {
_ = tblRows.Close()
t.Fatalf("scan table count: %v", err)
}
@@ -1151,6 +1154,7 @@ func TestMigration11ExploreCache(t *testing.T) {
&cid, &name, &colType, &notNull, &dfltValue, &pk,
); err != nil {
_ = colRows.Close()
t.Fatalf("scan table_info row: %v", err)
}
@@ -1182,6 +1186,7 @@ func TestMigration11ExploreCache(t *testing.T) {
if err := idxRows.Scan(&name); err != nil {
_ = idxRows.Close()
t.Fatalf("scan index name: %v", err)
}
@@ -1216,6 +1221,7 @@ func TestMigration11ExploreCache(t *testing.T) {
if !rows.Next() {
_ = rows.Close()
t.Fatal("explore_cache row not found")
}
@@ -2,6 +2,21 @@
INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *;
-- name: CreateAudioFileWithGroupKey :one
INSERT INTO audio_files (
file_path, length_milliseconds, file_type_id, recording_id,
sample_rate, bit_depth, channels, bitrate, file_size, basename,
library_id, group_key, tag_status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *;
-- name: GetAudioFileGroupKey :one
SELECT group_key FROM audio_files
WHERE id = ? LIMIT 1;
-- name: SetAudioFileGroupKey :exec
UPDATE audio_files SET group_key = ? WHERE id = ?;
-- name: GetAudioFile :one
SELECT * FROM audio_files
WHERE id = ? LIMIT 1;
@@ -19,3 +19,6 @@ DELETE FROM libraries WHERE id = ?;
-- name: CountLibraries :one
SELECT COUNT(*) AS count FROM libraries;
-- name: AckLibraryAutotagWarning :exec
UPDATE libraries SET autotag_warning_acked = 1 WHERE id = ?;
@@ -24,6 +24,13 @@ ON CONFLICT(name, album_artist_credit_id) DO UPDATE SET
year = COALESCE(excluded.year, release_groups.year)
RETURNING *;
-- name: SetReleaseGroupOriginalYear :exec
-- Set the release group's original-release-year (release-group's
-- first-release-date from MusicBrainz). Called from autotag apply
-- when the user confirms a candidate; the file-tag year stays in
-- the year column.
UPDATE release_groups SET original_year = ? WHERE id = ?;
-- name: UpdateReleaseGroup :exec
UPDATE release_groups
SET name = ?
@@ -49,7 +56,12 @@ ORDER BY name;
SELECT
rg.id,
rg.name,
rg.year,
-- year prefers original release year (MB first-release-date)
-- over the file-tag year so the UI surfaces the album's
-- original year by default. release_year keeps the file-tag
-- year accessible.
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
rg.mbid,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
COALESCE(ca.file_path, '') as cover_art_path
@@ -69,7 +81,12 @@ ORDER BY rg.name;
SELECT
rg.id,
rg.name,
rg.year,
-- year prefers original release year (MB first-release-date)
-- over the file-tag year so the UI surfaces the album's
-- original year by default. release_year keeps the file-tag
-- year accessible.
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
rg.mbid,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
COALESCE(ca.file_path, '') as cover_art_path
@@ -96,7 +113,8 @@ ORDER BY rg.name;
SELECT
rg.id,
rg.name,
rg.year,
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
@@ -120,7 +138,8 @@ SELECT COUNT(*) FROM release_group_recordings WHERE release_group_id = ?;
SELECT
rg.id,
rg.name,
rg.year,
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
@@ -0,0 +1,236 @@
-- name: UpsertTaggingItemOnTrackAdd :exec
INSERT INTO tagging_items (
group_key, library_id, track_count,
album_name, album_artist, disc_number, status
)
VALUES (?, ?, 1, ?, ?, ?, 'pending')
ON CONFLICT(group_key) DO UPDATE SET
track_count = tagging_items.track_count + 1,
album_name = CASE
WHEN tagging_items.album_name = '' THEN excluded.album_name
ELSE tagging_items.album_name
END,
album_artist = CASE
WHEN tagging_items.album_artist = '' THEN excluded.album_artist
ELSE tagging_items.album_artist
END;
-- name: DecrementTaggingItemTrackCount :exec
UPDATE tagging_items
SET track_count = track_count - 1
WHERE group_key = ?;
-- name: DeleteTaggingItemIfEmpty :exec
DELETE FROM tagging_items
WHERE group_key = ? AND track_count <= 0;
-- name: GetTaggingItem :one
SELECT * FROM tagging_items
WHERE group_key = ?
LIMIT 1;
-- name: CountPendingTaggingItems :one
SELECT COUNT(*) FROM tagging_items
WHERE status = 'pending'
AND (CAST(@library_id AS INTEGER) = 0 OR library_id = @library_id);
-- name: ListPendingTaggingItemsAlphabetical :many
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter)
AND ti.cleared_at IS NULL
ORDER BY LOWER(ti.album_artist), LOWER(ti.album_name), ti.disc_number
LIMIT @row_limit OFFSET @row_offset;
-- name: ListPendingTaggingItemsByScore :many
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
COALESCE(lb.path, '') AS library_path,
CAST(COALESCE((SELECT af.file_path FROM audio_files af WHERE af.group_key = ti.group_key LIMIT 1), '') AS TEXT) AS sample_file_path,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter)
AND ti.cleared_at IS NULL
ORDER BY ti.score IS NULL, ti.score DESC, LOWER(ti.album_artist), LOWER(ti.album_name)
LIMIT @row_limit OFFSET @row_offset;
-- name: ClearCompletedTaggingItems :exec
UPDATE tagging_items
SET cleared_at = CURRENT_TIMESTAMP
WHERE status = 'confirmed'
AND cleared_at IS NULL
AND (CAST(@library_id AS INTEGER) = 0 OR library_id = @library_id);
-- name: GetPendingFolderDetail :one
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
COALESCE(lb.path, '') AS library_path,
CAST(COALESCE((SELECT af.file_path FROM audio_files af WHERE af.group_key = ti.group_key LIMIT 1), '') AS TEXT) AS sample_file_path,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE ti.group_key = ?
LIMIT 1;
-- name: ListPendingTaggingItemsByRecent :many
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter)
ORDER BY ti.created_at DESC, ti.group_key
LIMIT @row_limit OFFSET @row_offset;
-- name: ListAudioFilesInTaggingGroup :many
SELECT
af.id,
af.file_path,
af.basename,
af.length_milliseconds,
af.tag_status,
COALESCE(r.track_number, 0) AS track_number,
COALESCE(r.disc_number, 0) AS disc_number,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
COALESCE(r.mbid, '') AS recording_mbid
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
WHERE af.group_key = ?
ORDER BY COALESCE(r.disc_number, 0),
COALESCE(r.track_number, 0),
af.file_path;
-- name: ListLocalReleaseGroupCandidates :many
-- Returns one row per (release_group, track) combination for any
-- local release_group that has an MBID. Callers group these in Go
-- and filter by normalized album-name match. Joined case-insensitive
-- on name to pre-filter cheaply; Go does the real normalization.
SELECT
rg.id AS release_group_id,
rg.mbid AS release_group_mbid,
rg.name AS album_name,
COALESCE(rg.year, 0) AS year,
COALESCE(ac.text, '') AS artist_credit,
COALESCE(rgr.track_number, 0) AS track_number,
COALESCE(rgr.disc_number, 0) AS disc_number,
COALESCE(r.name, '') AS track_title,
COALESCE(r.mbid, '') AS recording_mbid,
COALESCE(local_af.length_milliseconds, 0) AS length_milliseconds
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN recordings r ON r.id = rgr.recording_id
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
LEFT JOIN audio_files local_af ON local_af.recording_id = r.id
WHERE rg.mbid IS NOT NULL
AND rg.mbid != ''
AND r.mbid IS NOT NULL
AND r.mbid != ''
AND rg.name = ? COLLATE NOCASE
ORDER BY rg.id, rgr.disc_number, rgr.track_number;
-- name: SetTaggingItemBestMatch :exec
UPDATE tagging_items
SET best_match_release_mbid = ?,
score = ?,
status = ?,
last_checked_at = CURRENT_TIMESTAMP
WHERE group_key = ?;
-- name: SetTaggingItemScore :exec
UPDATE tagging_items
SET best_match_release_mbid = ?,
score = ?,
last_checked_at = CURRENT_TIMESTAMP
WHERE group_key = ?;
-- name: SetTaggingItemStatus :exec
UPDATE tagging_items
SET status = ?,
last_checked_at = CURRENT_TIMESTAMP
WHERE group_key = ?;
-- name: SetAudioFileTagStatus :exec
UPDATE audio_files SET tag_status = ? WHERE id = ?;
-- name: SetRecordingMBID :exec
UPDATE recordings SET mbid = ? WHERE id = ?;
-- name: SetReleaseGroupMBID :exec
UPDATE release_groups SET mbid = ? WHERE id = ?;
-- name: GetRecordingReleaseGroupID :one
SELECT COALESCE(rgr.release_group_id, 0) AS release_group_id
FROM release_group_recordings rgr
WHERE rgr.recording_id = ?
LIMIT 1;
-- name: GetNextPendingTaggingItem :one
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE ti.status = 'pending'
AND (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
AND ti.group_key > @after_group_key
ORDER BY ti.group_key
LIMIT 1;
+5 -4
View File
@@ -1,6 +1,7 @@
CREATE TABLE IF NOT EXISTS libraries (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
autotag_warning_acked INTEGER NOT NULL DEFAULT 0
);
@@ -13,6 +13,11 @@ CREATE TABLE IF NOT EXISTS audio_files (
library_id int NOT NULL DEFAULT 0,
play_count int NOT NULL DEFAULT 0,
last_played datetime,
tag_status TEXT NOT NULL DEFAULT 'untagged'
CHECK(tag_status IN (
'untagged', 'auto_matched', 'user_confirmed', 'user_skipped_permanent'
)),
group_key TEXT NOT NULL DEFAULT '',
FOREIGN KEY(file_type_id) REFERENCES file_types(id),
FOREIGN KEY(recording_id) REFERENCES recordings(id),
FOREIGN KEY(library_id) REFERENCES libraries(id)
@@ -24,3 +29,11 @@ CREATE INDEX IF NOT EXISTS idx_audio_files_recording_id
-- idx_audio_files_library_id is created by migration 6 (not here) because
-- on existing databases this schema file is a no-op (CREATE TABLE IF NOT EXISTS)
-- and the library_id column doesn't exist until the migration adds it.
--
-- idx_audio_files_tag_status_untagged + idx_audio_files_group_key are
-- created by migrations 31 and 32 for the same reason — on a pre-31
-- database the partial index predicates (`WHERE tag_status = '...'`
-- and `WHERE group_key != ''`) would reference columns that don't
-- yet exist, since CREATE TABLE IF NOT EXISTS does not add columns
-- to existing tables. sqlc still sees the columns above, and fresh
-- DBs pick up the indexes inside the migrations.
@@ -3,7 +3,18 @@ CREATE TABLE IF NOT EXISTS release_groups (
name TEXT NOT NULL,
cover_art_id INTEGER,
album_artist_credit_id INTEGER,
-- year is the *technical release year* of the album as it lives
-- in the user's library — typically the file's ID3 year tag,
-- which for remasters/reissues is the reissue year.
year INTEGER,
-- original_year is the album's *first-release-date* year sourced
-- from MusicBrainz' release-group.first-release-date. For a 2010
-- remaster of a 1973 album, year=2010 and original_year=1973.
-- Populated by autotag apply; NULL until the user accepts a
-- candidate (or for libraries that have never been autotagged).
-- Reads should COALESCE(original_year, year) to get the
-- preferred user-facing year.
original_year INTEGER,
total_tracks INTEGER,
total_discs INTEGER,
mbid TEXT,
@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS tagging_items (
group_key TEXT PRIMARY KEY,
library_id INTEGER NOT NULL,
track_count INTEGER NOT NULL DEFAULT 0,
album_name TEXT NOT NULL DEFAULT '',
album_artist TEXT NOT NULL DEFAULT '',
disc_number INTEGER NOT NULL DEFAULT 0,
best_match_release_mbid TEXT,
score REAL,
last_checked_at DATETIME,
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'matched', 'confirmed', 'skipped')),
-- cleared_at is set when the user explicitly removes the item
-- from the queue ("clear completed entries"). Cleared rows are
-- excluded from the queue list but kept in the table so a
-- subsequent rescan of the same folder doesn't reset the
-- review state.
cleared_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(library_id) REFERENCES libraries(id)
);
CREATE INDEX IF NOT EXISTS idx_tagging_items_library_status
ON tagging_items(library_id, status);
CREATE INDEX IF NOT EXISTS idx_tagging_items_status_pending
ON tagging_items(library_id) WHERE status = 'pending';
@@ -15,7 +15,13 @@ SELECT
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
-- Year defaults to the release group's original release year
-- (MusicBrainz first-release-date) so a 1973 album shows as
-- 1973 even if the user owns the 2010 remaster. Falls back
-- to release-group year (file tag), then to recording year.
-- See release_groups.original_year for full semantics.
COALESCE(rg.original_year, rg.year, r.year, 0) AS year,
COALESCE(rg.year, r.year, 0) AS release_year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
+107 -6
View File
@@ -35,7 +35,7 @@ func (q *Queries) CountAudioFilesByLibrary(ctx context.Context, libraryID int64)
const createAudioFile = `-- name: CreateAudioFile :one
INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played
RETURNING id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key
`
type CreateAudioFileParams struct {
@@ -82,6 +82,71 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams
&i.LibraryID,
&i.PlayCount,
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
)
return i, err
}
const createAudioFileWithGroupKey = `-- name: CreateAudioFileWithGroupKey :one
INSERT INTO audio_files (
file_path, length_milliseconds, file_type_id, recording_id,
sample_rate, bit_depth, channels, bitrate, file_size, basename,
library_id, group_key, tag_status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key
`
type CreateAudioFileWithGroupKeyParams struct {
FilePath string
LengthMilliseconds int64
FileTypeID int64
RecordingID int64
SampleRate int64
BitDepth int64
Channels int64
Bitrate int64
FileSize int64
Basename string
LibraryID int64
GroupKey string
TagStatus string
}
func (q *Queries) CreateAudioFileWithGroupKey(ctx context.Context, arg CreateAudioFileWithGroupKeyParams) (AudioFile, error) {
row := q.db.QueryRowContext(ctx, createAudioFileWithGroupKey,
arg.FilePath,
arg.LengthMilliseconds,
arg.FileTypeID,
arg.RecordingID,
arg.SampleRate,
arg.BitDepth,
arg.Channels,
arg.Bitrate,
arg.FileSize,
arg.Basename,
arg.LibraryID,
arg.GroupKey,
arg.TagStatus,
)
var i AudioFile
err := row.Scan(
&i.ID,
&i.FilePath,
&i.LengthMilliseconds,
&i.FileTypeID,
&i.RecordingID,
&i.SampleRate,
&i.BitDepth,
&i.Channels,
&i.Bitrate,
&i.FileSize,
&i.Basename,
&i.LibraryID,
&i.PlayCount,
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
)
return i, err
}
@@ -138,7 +203,7 @@ func (q *Queries) GetAllAudioFilePaths(ctx context.Context) ([]GetAllAudioFilePa
}
const getAllAudioFiles = `-- name: GetAllAudioFiles :many
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played FROM audio_files
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key FROM audio_files
`
func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error) {
@@ -165,6 +230,8 @@ func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error) {
&i.LibraryID,
&i.PlayCount,
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
); err != nil {
return nil, err
}
@@ -460,7 +527,7 @@ func (q *Queries) GetAllTracksWithFullMetadataByLibrary(ctx context.Context, lib
}
const getAudioFile = `-- name: GetAudioFile :one
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played FROM audio_files
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key FROM audio_files
WHERE id = ? LIMIT 1
`
@@ -482,12 +549,14 @@ func (q *Queries) GetAudioFile(ctx context.Context, id int64) (AudioFile, error)
&i.LibraryID,
&i.PlayCount,
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
)
return i, err
}
const getAudioFileByPath = `-- name: GetAudioFileByPath :one
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played FROM audio_files
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key FROM audio_files
WHERE file_path = ? LIMIT 1
`
@@ -509,12 +578,26 @@ func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (Audi
&i.LibraryID,
&i.PlayCount,
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
)
return i, err
}
const getAudioFileGroupKey = `-- name: GetAudioFileGroupKey :one
SELECT group_key FROM audio_files
WHERE id = ? LIMIT 1
`
func (q *Queries) GetAudioFileGroupKey(ctx context.Context, id int64) (string, error) {
row := q.db.QueryRowContext(ctx, getAudioFileGroupKey, id)
var group_key string
err := row.Scan(&group_key)
return group_key, err
}
const getAudioFilesByLibrary = `-- name: GetAudioFilesByLibrary :many
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played FROM audio_files WHERE library_id = ?
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key FROM audio_files WHERE library_id = ?
`
func (q *Queries) GetAudioFilesByLibrary(ctx context.Context, libraryID int64) ([]AudioFile, error) {
@@ -541,6 +624,8 @@ func (q *Queries) GetAudioFilesByLibrary(ctx context.Context, libraryID int64) (
&i.LibraryID,
&i.PlayCount,
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
); err != nil {
return nil, err
}
@@ -769,7 +854,7 @@ func (q *Queries) GetAudioFilesByReleaseGroupByLibrary(ctx context.Context, arg
}
const getAudioFilesNeedingMetadata = `-- name: GetAudioFilesNeedingMetadata :many
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played FROM audio_files
SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key FROM audio_files
WHERE recording_id = 0
`
@@ -797,6 +882,8 @@ func (q *Queries) GetAudioFilesNeedingMetadata(ctx context.Context) ([]AudioFile
&i.LibraryID,
&i.PlayCount,
&i.LastPlayed,
&i.TagStatus,
&i.GroupKey,
); err != nil {
return nil, err
}
@@ -999,6 +1086,20 @@ func (q *Queries) SearchAudioFilesByBasename(ctx context.Context, arg SearchAudi
return items, nil
}
const setAudioFileGroupKey = `-- name: SetAudioFileGroupKey :exec
UPDATE audio_files SET group_key = ? WHERE id = ?
`
type SetAudioFileGroupKeyParams struct {
GroupKey string
ID int64
}
func (q *Queries) SetAudioFileGroupKey(ctx context.Context, arg SetAudioFileGroupKeyParams) error {
_, err := q.db.ExecContext(ctx, setAudioFileGroupKey, arg.GroupKey, arg.ID)
return err
}
const updateAudioFile = `-- name: UpdateAudioFile :exec
UPDATE audio_files
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, basename = ?
+17 -4
View File
@@ -9,6 +9,15 @@ import (
"context"
)
const ackLibraryAutotagWarning = `-- name: AckLibraryAutotagWarning :exec
UPDATE libraries SET autotag_warning_acked = 1 WHERE id = ?
`
func (q *Queries) AckLibraryAutotagWarning(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, ackLibraryAutotagWarning, id)
return err
}
const countLibraries = `-- name: CountLibraries :one
SELECT COUNT(*) AS count FROM libraries
`
@@ -22,7 +31,7 @@ func (q *Queries) CountLibraries(ctx context.Context) (int64, error) {
const createLibrary = `-- name: CreateLibrary :one
INSERT INTO libraries (name, path) VALUES (?, ?)
RETURNING id, name, path, created_at
RETURNING id, name, path, created_at, autotag_warning_acked
`
type CreateLibraryParams struct {
@@ -38,6 +47,7 @@ func (q *Queries) CreateLibrary(ctx context.Context, arg CreateLibraryParams) (L
&i.Name,
&i.Path,
&i.CreatedAt,
&i.AutotagWarningAcked,
)
return i, err
}
@@ -52,7 +62,7 @@ func (q *Queries) DeleteLibrary(ctx context.Context, id int64) error {
}
const getAllLibraries = `-- name: GetAllLibraries :many
SELECT id, name, path, created_at FROM libraries ORDER BY name
SELECT id, name, path, created_at, autotag_warning_acked FROM libraries ORDER BY name
`
func (q *Queries) GetAllLibraries(ctx context.Context) ([]Library, error) {
@@ -69,6 +79,7 @@ func (q *Queries) GetAllLibraries(ctx context.Context) ([]Library, error) {
&i.Name,
&i.Path,
&i.CreatedAt,
&i.AutotagWarningAcked,
); err != nil {
return nil, err
}
@@ -84,7 +95,7 @@ func (q *Queries) GetAllLibraries(ctx context.Context) ([]Library, error) {
}
const getLibrary = `-- name: GetLibrary :one
SELECT id, name, path, created_at FROM libraries WHERE id = ? LIMIT 1
SELECT id, name, path, created_at, autotag_warning_acked FROM libraries WHERE id = ? LIMIT 1
`
func (q *Queries) GetLibrary(ctx context.Context, id int64) (Library, error) {
@@ -95,12 +106,13 @@ func (q *Queries) GetLibrary(ctx context.Context, id int64) (Library, error) {
&i.Name,
&i.Path,
&i.CreatedAt,
&i.AutotagWarningAcked,
)
return i, err
}
const getLibraryByPath = `-- name: GetLibraryByPath :one
SELECT id, name, path, created_at FROM libraries WHERE path = ? LIMIT 1
SELECT id, name, path, created_at, autotag_warning_acked FROM libraries WHERE path = ? LIMIT 1
`
func (q *Queries) GetLibraryByPath(ctx context.Context, path string) (Library, error) {
@@ -111,6 +123,7 @@ func (q *Queries) GetLibraryByPath(ctx context.Context, path string) (Library, e
&i.Name,
&i.Path,
&i.CreatedAt,
&i.AutotagWarningAcked,
)
return i, err
}
+24 -4
View File
@@ -48,6 +48,8 @@ type AudioFile struct {
LibraryID int64
PlayCount int64
LastPlayed sql.NullTime
TagStatus string
GroupKey string
}
type CoverArt struct {
@@ -76,10 +78,11 @@ type HttpCache struct {
}
type Library struct {
ID int64
Name string
Path string
CreatedAt time.Time
ID int64
Name string
Path string
CreatedAt time.Time
AutotagWarningAcked int64
}
type PlayHistory struct {
@@ -160,6 +163,7 @@ type ReleaseGroup struct {
CoverArtID sql.NullInt64
AlbumArtistCreditID sql.NullInt64
Year sql.NullInt64
OriginalYear sql.NullInt64
TotalTracks sql.NullInt64
TotalDiscs sql.NullInt64
Mbid sql.NullString
@@ -180,6 +184,21 @@ type SearchIndex struct {
Album string
}
type TaggingItem struct {
GroupKey string
LibraryID int64
TrackCount int64
AlbumName string
AlbumArtist string
DiscNumber int64
BestMatchReleaseMbid sql.NullString
Score sql.NullFloat64
LastCheckedAt sql.NullTime
Status string
ClearedAt sql.NullTime
CreatedAt time.Time
}
type TrackMetadatum struct {
ID int64
FilePath string
@@ -191,6 +210,7 @@ type TrackMetadatum struct {
Album string
Genre string
Year int64
ReleaseYear int64
Composer string
FileType string
SampleRate int64
@@ -23,7 +23,7 @@ func (q *Queries) CountReleaseGroupRecordings(ctx context.Context, releaseGroupI
const createReleaseGroup = `-- name: CreateReleaseGroup :one
INSERT INTO release_groups (name) VALUES (?)
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid
RETURNING id, name, cover_art_id, album_artist_credit_id, year, original_year, total_tracks, total_discs, mbid
`
func (q *Queries) CreateReleaseGroup(ctx context.Context, name string) (ReleaseGroup, error) {
@@ -35,6 +35,7 @@ func (q *Queries) CreateReleaseGroup(ctx context.Context, name string) (ReleaseG
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.OriginalYear,
&i.TotalTracks,
&i.TotalDiscs,
&i.Mbid,
@@ -46,7 +47,7 @@ const createReleaseGroupFull = `-- name: CreateReleaseGroupFull :one
INSERT INTO release_groups (
name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
) VALUES (?, ?, ?, ?, ?, ?)
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid
RETURNING id, name, cover_art_id, album_artist_credit_id, year, original_year, total_tracks, total_discs, mbid
`
type CreateReleaseGroupFullParams struct {
@@ -74,6 +75,7 @@ func (q *Queries) CreateReleaseGroupFull(ctx context.Context, arg CreateReleaseG
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.OriginalYear,
&i.TotalTracks,
&i.TotalDiscs,
&i.Mbid,
@@ -104,7 +106,8 @@ const getAlbumsByArtist = `-- name: GetAlbumsByArtist :many
SELECT
rg.id,
rg.name,
rg.year,
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
@@ -126,6 +129,7 @@ type GetAlbumsByArtistRow struct {
ID int64
Name string
Year sql.NullInt64
ReleaseYear int64
ArtistName string
CoverArtPath string
}
@@ -143,6 +147,7 @@ func (q *Queries) GetAlbumsByArtist(ctx context.Context, artistID int64) ([]GetA
&i.ID,
&i.Name,
&i.Year,
&i.ReleaseYear,
&i.ArtistName,
&i.CoverArtPath,
); err != nil {
@@ -163,7 +168,8 @@ const getAlbumsByArtistByLibrary = `-- name: GetAlbumsByArtistByLibrary :many
SELECT
rg.id,
rg.name,
rg.year,
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
@@ -197,6 +203,7 @@ type GetAlbumsByArtistByLibraryRow struct {
ID int64
Name string
Year sql.NullInt64
ReleaseYear int64
ArtistName string
CoverArtPath string
}
@@ -214,6 +221,7 @@ func (q *Queries) GetAlbumsByArtistByLibrary(ctx context.Context, arg GetAlbumsB
&i.ID,
&i.Name,
&i.Year,
&i.ReleaseYear,
&i.ArtistName,
&i.CoverArtPath,
); err != nil {
@@ -234,7 +242,12 @@ const getAllAlbumsWithDetails = `-- name: GetAllAlbumsWithDetails :many
SELECT
rg.id,
rg.name,
rg.year,
-- year prefers original release year (MB first-release-date)
-- over the file-tag year so the UI surfaces the album's
-- original year by default. release_year keeps the file-tag
-- year accessible.
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
rg.mbid,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
COALESCE(ca.file_path, '') as cover_art_path
@@ -255,6 +268,7 @@ type GetAllAlbumsWithDetailsRow struct {
ID int64
Name string
Year sql.NullInt64
ReleaseYear int64
Mbid sql.NullString
ArtistName string
CoverArtPath string
@@ -273,6 +287,7 @@ func (q *Queries) GetAllAlbumsWithDetails(ctx context.Context) ([]GetAllAlbumsWi
&i.ID,
&i.Name,
&i.Year,
&i.ReleaseYear,
&i.Mbid,
&i.ArtistName,
&i.CoverArtPath,
@@ -294,7 +309,12 @@ const getAllAlbumsWithDetailsByLibrary = `-- name: GetAllAlbumsWithDetailsByLibr
SELECT
rg.id,
rg.name,
rg.year,
-- year prefers original release year (MB first-release-date)
-- over the file-tag year so the UI surfaces the album's
-- original year by default. release_year keeps the file-tag
-- year accessible.
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
rg.mbid,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
COALESCE(ca.file_path, '') as cover_art_path
@@ -322,6 +342,7 @@ type GetAllAlbumsWithDetailsByLibraryRow struct {
ID int64
Name string
Year sql.NullInt64
ReleaseYear int64
Mbid sql.NullString
ArtistName string
CoverArtPath string
@@ -340,6 +361,7 @@ func (q *Queries) GetAllAlbumsWithDetailsByLibrary(ctx context.Context, libraryI
&i.ID,
&i.Name,
&i.Year,
&i.ReleaseYear,
&i.Mbid,
&i.ArtistName,
&i.CoverArtPath,
@@ -358,7 +380,7 @@ func (q *Queries) GetAllAlbumsWithDetailsByLibrary(ctx context.Context, libraryI
}
const getAllReleaseGroups = `-- name: GetAllReleaseGroups :many
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid FROM release_groups
SELECT id, name, cover_art_id, album_artist_credit_id, year, original_year, total_tracks, total_discs, mbid FROM release_groups
ORDER BY name
`
@@ -377,6 +399,7 @@ func (q *Queries) GetAllReleaseGroups(ctx context.Context) ([]ReleaseGroup, erro
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.OriginalYear,
&i.TotalTracks,
&i.TotalDiscs,
&i.Mbid,
@@ -395,7 +418,7 @@ func (q *Queries) GetAllReleaseGroups(ctx context.Context) ([]ReleaseGroup, erro
}
const getReleaseGroup = `-- name: GetReleaseGroup :one
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid FROM release_groups
SELECT id, name, cover_art_id, album_artist_credit_id, year, original_year, total_tracks, total_discs, mbid FROM release_groups
WHERE id = ? LIMIT 1
`
@@ -408,6 +431,7 @@ func (q *Queries) GetReleaseGroup(ctx context.Context, id int64) (ReleaseGroup,
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.OriginalYear,
&i.TotalTracks,
&i.TotalDiscs,
&i.Mbid,
@@ -416,7 +440,7 @@ func (q *Queries) GetReleaseGroup(ctx context.Context, id int64) (ReleaseGroup,
}
const getReleaseGroupByNameAndArtist = `-- name: GetReleaseGroupByNameAndArtist :one
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid FROM release_groups
SELECT id, name, cover_art_id, album_artist_credit_id, year, original_year, total_tracks, total_discs, mbid FROM release_groups
WHERE name = ? AND album_artist_credit_id = ? LIMIT 1
`
@@ -434,6 +458,7 @@ func (q *Queries) GetReleaseGroupByNameAndArtist(ctx context.Context, arg GetRel
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.OriginalYear,
&i.TotalTracks,
&i.TotalDiscs,
&i.Mbid,
@@ -441,6 +466,24 @@ func (q *Queries) GetReleaseGroupByNameAndArtist(ctx context.Context, arg GetRel
return i, err
}
const setReleaseGroupOriginalYear = `-- name: SetReleaseGroupOriginalYear :exec
UPDATE release_groups SET original_year = ? WHERE id = ?
`
type SetReleaseGroupOriginalYearParams struct {
OriginalYear sql.NullInt64
ID int64
}
// Set the release group's original-release-year (release-group's
// first-release-date from MusicBrainz). Called from autotag apply
// when the user confirms a candidate; the file-tag year stays in
// the year column.
func (q *Queries) SetReleaseGroupOriginalYear(ctx context.Context, arg SetReleaseGroupOriginalYearParams) error {
_, err := q.db.ExecContext(ctx, setReleaseGroupOriginalYear, arg.OriginalYear, arg.ID)
return err
}
const updateReleaseGroup = `-- name: UpdateReleaseGroup :exec
UPDATE release_groups
SET name = ?
@@ -479,7 +522,7 @@ VALUES (?, ?, ?)
ON CONFLICT(name, album_artist_credit_id) DO UPDATE SET
album_artist_credit_id = COALESCE(excluded.album_artist_credit_id, release_groups.album_artist_credit_id),
year = COALESCE(excluded.year, release_groups.year)
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid
RETURNING id, name, cover_art_id, album_artist_credit_id, year, original_year, total_tracks, total_discs, mbid
`
type UpsertReleaseGroupParams struct {
@@ -497,6 +540,7 @@ func (q *Queries) UpsertReleaseGroup(ctx context.Context, arg UpsertReleaseGroup
&i.CoverArtID,
&i.AlbumArtistCreditID,
&i.Year,
&i.OriginalYear,
&i.TotalTracks,
&i.TotalDiscs,
&i.Mbid,
@@ -0,0 +1,771 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: tagging_items.sql
package sqlcgen
import (
"context"
"database/sql"
"time"
)
const clearCompletedTaggingItems = `-- name: ClearCompletedTaggingItems :exec
UPDATE tagging_items
SET cleared_at = CURRENT_TIMESTAMP
WHERE status = 'confirmed'
AND cleared_at IS NULL
AND (CAST(?1 AS INTEGER) = 0 OR library_id = ?1)
`
func (q *Queries) ClearCompletedTaggingItems(ctx context.Context, libraryID int64) error {
_, err := q.db.ExecContext(ctx, clearCompletedTaggingItems, libraryID)
return err
}
const countPendingTaggingItems = `-- name: CountPendingTaggingItems :one
SELECT COUNT(*) FROM tagging_items
WHERE status = 'pending'
AND (CAST(?1 AS INTEGER) = 0 OR library_id = ?1)
`
func (q *Queries) CountPendingTaggingItems(ctx context.Context, libraryID int64) (int64, error) {
row := q.db.QueryRowContext(ctx, countPendingTaggingItems, libraryID)
var count int64
err := row.Scan(&count)
return count, err
}
const decrementTaggingItemTrackCount = `-- name: DecrementTaggingItemTrackCount :exec
UPDATE tagging_items
SET track_count = track_count - 1
WHERE group_key = ?
`
func (q *Queries) DecrementTaggingItemTrackCount(ctx context.Context, groupKey string) error {
_, err := q.db.ExecContext(ctx, decrementTaggingItemTrackCount, groupKey)
return err
}
const deleteTaggingItemIfEmpty = `-- name: DeleteTaggingItemIfEmpty :exec
DELETE FROM tagging_items
WHERE group_key = ? AND track_count <= 0
`
func (q *Queries) DeleteTaggingItemIfEmpty(ctx context.Context, groupKey string) error {
_, err := q.db.ExecContext(ctx, deleteTaggingItemIfEmpty, groupKey)
return err
}
const getNextPendingTaggingItem = `-- name: GetNextPendingTaggingItem :one
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE ti.status = 'pending'
AND (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
AND ti.group_key > ?2
ORDER BY ti.group_key
LIMIT 1
`
type GetNextPendingTaggingItemParams struct {
LibraryID int64
AfterGroupKey string
}
type GetNextPendingTaggingItemRow struct {
GroupKey string
LibraryID int64
LibraryName string
TrackCount int64
AlbumName string
AlbumArtist string
DiscNumber int64
BestMatchReleaseMbid sql.NullString
Score sql.NullFloat64
LastCheckedAt sql.NullTime
Status string
CreatedAt time.Time
}
func (q *Queries) GetNextPendingTaggingItem(ctx context.Context, arg GetNextPendingTaggingItemParams) (GetNextPendingTaggingItemRow, error) {
row := q.db.QueryRowContext(ctx, getNextPendingTaggingItem, arg.LibraryID, arg.AfterGroupKey)
var i GetNextPendingTaggingItemRow
err := row.Scan(
&i.GroupKey,
&i.LibraryID,
&i.LibraryName,
&i.TrackCount,
&i.AlbumName,
&i.AlbumArtist,
&i.DiscNumber,
&i.BestMatchReleaseMbid,
&i.Score,
&i.LastCheckedAt,
&i.Status,
&i.CreatedAt,
)
return i, err
}
const getPendingFolderDetail = `-- name: GetPendingFolderDetail :one
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
COALESCE(lb.path, '') AS library_path,
CAST(COALESCE((SELECT af.file_path FROM audio_files af WHERE af.group_key = ti.group_key LIMIT 1), '') AS TEXT) AS sample_file_path,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE ti.group_key = ?
LIMIT 1
`
type GetPendingFolderDetailRow struct {
GroupKey string
LibraryID int64
LibraryName string
LibraryPath string
SampleFilePath string
TrackCount int64
AlbumName string
AlbumArtist string
DiscNumber int64
BestMatchReleaseMbid sql.NullString
Score sql.NullFloat64
LastCheckedAt sql.NullTime
Status string
CreatedAt time.Time
}
func (q *Queries) GetPendingFolderDetail(ctx context.Context, groupKey string) (GetPendingFolderDetailRow, error) {
row := q.db.QueryRowContext(ctx, getPendingFolderDetail, groupKey)
var i GetPendingFolderDetailRow
err := row.Scan(
&i.GroupKey,
&i.LibraryID,
&i.LibraryName,
&i.LibraryPath,
&i.SampleFilePath,
&i.TrackCount,
&i.AlbumName,
&i.AlbumArtist,
&i.DiscNumber,
&i.BestMatchReleaseMbid,
&i.Score,
&i.LastCheckedAt,
&i.Status,
&i.CreatedAt,
)
return i, err
}
const getRecordingReleaseGroupID = `-- name: GetRecordingReleaseGroupID :one
SELECT COALESCE(rgr.release_group_id, 0) AS release_group_id
FROM release_group_recordings rgr
WHERE rgr.recording_id = ?
LIMIT 1
`
func (q *Queries) GetRecordingReleaseGroupID(ctx context.Context, recordingID int64) (int64, error) {
row := q.db.QueryRowContext(ctx, getRecordingReleaseGroupID, recordingID)
var release_group_id int64
err := row.Scan(&release_group_id)
return release_group_id, err
}
const getTaggingItem = `-- name: GetTaggingItem :one
SELECT group_key, library_id, track_count, album_name, album_artist, disc_number, best_match_release_mbid, score, last_checked_at, status, cleared_at, created_at FROM tagging_items
WHERE group_key = ?
LIMIT 1
`
func (q *Queries) GetTaggingItem(ctx context.Context, groupKey string) (TaggingItem, error) {
row := q.db.QueryRowContext(ctx, getTaggingItem, groupKey)
var i TaggingItem
err := row.Scan(
&i.GroupKey,
&i.LibraryID,
&i.TrackCount,
&i.AlbumName,
&i.AlbumArtist,
&i.DiscNumber,
&i.BestMatchReleaseMbid,
&i.Score,
&i.LastCheckedAt,
&i.Status,
&i.ClearedAt,
&i.CreatedAt,
)
return i, err
}
const listAudioFilesInTaggingGroup = `-- name: ListAudioFilesInTaggingGroup :many
SELECT
af.id,
af.file_path,
af.basename,
af.length_milliseconds,
af.tag_status,
COALESCE(r.track_number, 0) AS track_number,
COALESCE(r.disc_number, 0) AS disc_number,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
COALESCE(r.mbid, '') AS recording_mbid
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
WHERE af.group_key = ?
ORDER BY COALESCE(r.disc_number, 0),
COALESCE(r.track_number, 0),
af.file_path
`
type ListAudioFilesInTaggingGroupRow struct {
ID int64
FilePath string
Basename string
LengthMilliseconds int64
TagStatus string
TrackNumber int64
DiscNumber int64
Title string
ArtistName string
RecordingMbid string
}
func (q *Queries) ListAudioFilesInTaggingGroup(ctx context.Context, groupKey string) ([]ListAudioFilesInTaggingGroupRow, error) {
rows, err := q.db.QueryContext(ctx, listAudioFilesInTaggingGroup, groupKey)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAudioFilesInTaggingGroupRow
for rows.Next() {
var i ListAudioFilesInTaggingGroupRow
if err := rows.Scan(
&i.ID,
&i.FilePath,
&i.Basename,
&i.LengthMilliseconds,
&i.TagStatus,
&i.TrackNumber,
&i.DiscNumber,
&i.Title,
&i.ArtistName,
&i.RecordingMbid,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listLocalReleaseGroupCandidates = `-- name: ListLocalReleaseGroupCandidates :many
SELECT
rg.id AS release_group_id,
rg.mbid AS release_group_mbid,
rg.name AS album_name,
COALESCE(rg.year, 0) AS year,
COALESCE(ac.text, '') AS artist_credit,
COALESCE(rgr.track_number, 0) AS track_number,
COALESCE(rgr.disc_number, 0) AS disc_number,
COALESCE(r.name, '') AS track_title,
COALESCE(r.mbid, '') AS recording_mbid,
COALESCE(local_af.length_milliseconds, 0) AS length_milliseconds
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN recordings r ON r.id = rgr.recording_id
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
LEFT JOIN audio_files local_af ON local_af.recording_id = r.id
WHERE rg.mbid IS NOT NULL
AND rg.mbid != ''
AND r.mbid IS NOT NULL
AND r.mbid != ''
AND rg.name = ? COLLATE NOCASE
ORDER BY rg.id, rgr.disc_number, rgr.track_number
`
type ListLocalReleaseGroupCandidatesRow struct {
ReleaseGroupID int64
ReleaseGroupMbid sql.NullString
AlbumName string
Year int64
ArtistCredit string
TrackNumber int64
DiscNumber int64
TrackTitle string
RecordingMbid string
LengthMilliseconds int64
}
// Returns one row per (release_group, track) combination for any
// local release_group that has an MBID. Callers group these in Go
// and filter by normalized album-name match. Joined case-insensitive
// on name to pre-filter cheaply; Go does the real normalization.
func (q *Queries) ListLocalReleaseGroupCandidates(ctx context.Context, name string) ([]ListLocalReleaseGroupCandidatesRow, error) {
rows, err := q.db.QueryContext(ctx, listLocalReleaseGroupCandidates, name)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListLocalReleaseGroupCandidatesRow
for rows.Next() {
var i ListLocalReleaseGroupCandidatesRow
if err := rows.Scan(
&i.ReleaseGroupID,
&i.ReleaseGroupMbid,
&i.AlbumName,
&i.Year,
&i.ArtistCredit,
&i.TrackNumber,
&i.DiscNumber,
&i.TrackTitle,
&i.RecordingMbid,
&i.LengthMilliseconds,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listPendingTaggingItemsAlphabetical = `-- name: ListPendingTaggingItemsAlphabetical :many
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
AND (CAST(?2 AS TEXT) = 'all' OR ti.status = ?2)
AND ti.cleared_at IS NULL
ORDER BY LOWER(ti.album_artist), LOWER(ti.album_name), ti.disc_number
LIMIT ?4 OFFSET ?3
`
type ListPendingTaggingItemsAlphabeticalParams struct {
LibraryID int64
StatusFilter string
RowOffset int64
RowLimit int64
}
type ListPendingTaggingItemsAlphabeticalRow struct {
GroupKey string
LibraryID int64
LibraryName string
TrackCount int64
AlbumName string
AlbumArtist string
DiscNumber int64
BestMatchReleaseMbid sql.NullString
Score sql.NullFloat64
LastCheckedAt sql.NullTime
Status string
CreatedAt time.Time
}
func (q *Queries) ListPendingTaggingItemsAlphabetical(ctx context.Context, arg ListPendingTaggingItemsAlphabeticalParams) ([]ListPendingTaggingItemsAlphabeticalRow, error) {
rows, err := q.db.QueryContext(ctx, listPendingTaggingItemsAlphabetical,
arg.LibraryID,
arg.StatusFilter,
arg.RowOffset,
arg.RowLimit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPendingTaggingItemsAlphabeticalRow
for rows.Next() {
var i ListPendingTaggingItemsAlphabeticalRow
if err := rows.Scan(
&i.GroupKey,
&i.LibraryID,
&i.LibraryName,
&i.TrackCount,
&i.AlbumName,
&i.AlbumArtist,
&i.DiscNumber,
&i.BestMatchReleaseMbid,
&i.Score,
&i.LastCheckedAt,
&i.Status,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listPendingTaggingItemsByRecent = `-- name: ListPendingTaggingItemsByRecent :many
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
AND (CAST(?2 AS TEXT) = 'all' OR ti.status = ?2)
ORDER BY ti.created_at DESC, ti.group_key
LIMIT ?4 OFFSET ?3
`
type ListPendingTaggingItemsByRecentParams struct {
LibraryID int64
StatusFilter string
RowOffset int64
RowLimit int64
}
type ListPendingTaggingItemsByRecentRow struct {
GroupKey string
LibraryID int64
LibraryName string
TrackCount int64
AlbumName string
AlbumArtist string
DiscNumber int64
BestMatchReleaseMbid sql.NullString
Score sql.NullFloat64
LastCheckedAt sql.NullTime
Status string
CreatedAt time.Time
}
func (q *Queries) ListPendingTaggingItemsByRecent(ctx context.Context, arg ListPendingTaggingItemsByRecentParams) ([]ListPendingTaggingItemsByRecentRow, error) {
rows, err := q.db.QueryContext(ctx, listPendingTaggingItemsByRecent,
arg.LibraryID,
arg.StatusFilter,
arg.RowOffset,
arg.RowLimit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPendingTaggingItemsByRecentRow
for rows.Next() {
var i ListPendingTaggingItemsByRecentRow
if err := rows.Scan(
&i.GroupKey,
&i.LibraryID,
&i.LibraryName,
&i.TrackCount,
&i.AlbumName,
&i.AlbumArtist,
&i.DiscNumber,
&i.BestMatchReleaseMbid,
&i.Score,
&i.LastCheckedAt,
&i.Status,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listPendingTaggingItemsByScore = `-- name: ListPendingTaggingItemsByScore :many
SELECT
ti.group_key,
ti.library_id,
COALESCE(lb.name, '') AS library_name,
COALESCE(lb.path, '') AS library_path,
CAST(COALESCE((SELECT af.file_path FROM audio_files af WHERE af.group_key = ti.group_key LIMIT 1), '') AS TEXT) AS sample_file_path,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.disc_number,
ti.best_match_release_mbid,
ti.score,
ti.last_checked_at,
ti.status,
ti.created_at
FROM tagging_items ti
LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
AND (CAST(?2 AS TEXT) = 'all' OR ti.status = ?2)
AND ti.cleared_at IS NULL
ORDER BY ti.score IS NULL, ti.score DESC, LOWER(ti.album_artist), LOWER(ti.album_name)
LIMIT ?4 OFFSET ?3
`
type ListPendingTaggingItemsByScoreParams struct {
LibraryID int64
StatusFilter string
RowOffset int64
RowLimit int64
}
type ListPendingTaggingItemsByScoreRow struct {
GroupKey string
LibraryID int64
LibraryName string
LibraryPath string
SampleFilePath string
TrackCount int64
AlbumName string
AlbumArtist string
DiscNumber int64
BestMatchReleaseMbid sql.NullString
Score sql.NullFloat64
LastCheckedAt sql.NullTime
Status string
CreatedAt time.Time
}
func (q *Queries) ListPendingTaggingItemsByScore(ctx context.Context, arg ListPendingTaggingItemsByScoreParams) ([]ListPendingTaggingItemsByScoreRow, error) {
rows, err := q.db.QueryContext(ctx, listPendingTaggingItemsByScore,
arg.LibraryID,
arg.StatusFilter,
arg.RowOffset,
arg.RowLimit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPendingTaggingItemsByScoreRow
for rows.Next() {
var i ListPendingTaggingItemsByScoreRow
if err := rows.Scan(
&i.GroupKey,
&i.LibraryID,
&i.LibraryName,
&i.LibraryPath,
&i.SampleFilePath,
&i.TrackCount,
&i.AlbumName,
&i.AlbumArtist,
&i.DiscNumber,
&i.BestMatchReleaseMbid,
&i.Score,
&i.LastCheckedAt,
&i.Status,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setAudioFileTagStatus = `-- name: SetAudioFileTagStatus :exec
UPDATE audio_files SET tag_status = ? WHERE id = ?
`
type SetAudioFileTagStatusParams struct {
TagStatus string
ID int64
}
func (q *Queries) SetAudioFileTagStatus(ctx context.Context, arg SetAudioFileTagStatusParams) error {
_, err := q.db.ExecContext(ctx, setAudioFileTagStatus, arg.TagStatus, arg.ID)
return err
}
const setRecordingMBID = `-- name: SetRecordingMBID :exec
UPDATE recordings SET mbid = ? WHERE id = ?
`
type SetRecordingMBIDParams struct {
Mbid sql.NullString
ID int64
}
func (q *Queries) SetRecordingMBID(ctx context.Context, arg SetRecordingMBIDParams) error {
_, err := q.db.ExecContext(ctx, setRecordingMBID, arg.Mbid, arg.ID)
return err
}
const setReleaseGroupMBID = `-- name: SetReleaseGroupMBID :exec
UPDATE release_groups SET mbid = ? WHERE id = ?
`
type SetReleaseGroupMBIDParams struct {
Mbid sql.NullString
ID int64
}
func (q *Queries) SetReleaseGroupMBID(ctx context.Context, arg SetReleaseGroupMBIDParams) error {
_, err := q.db.ExecContext(ctx, setReleaseGroupMBID, arg.Mbid, arg.ID)
return err
}
const setTaggingItemBestMatch = `-- name: SetTaggingItemBestMatch :exec
UPDATE tagging_items
SET best_match_release_mbid = ?,
score = ?,
status = ?,
last_checked_at = CURRENT_TIMESTAMP
WHERE group_key = ?
`
type SetTaggingItemBestMatchParams struct {
BestMatchReleaseMbid sql.NullString
Score sql.NullFloat64
Status string
GroupKey string
}
func (q *Queries) SetTaggingItemBestMatch(ctx context.Context, arg SetTaggingItemBestMatchParams) error {
_, err := q.db.ExecContext(ctx, setTaggingItemBestMatch,
arg.BestMatchReleaseMbid,
arg.Score,
arg.Status,
arg.GroupKey,
)
return err
}
const setTaggingItemScore = `-- name: SetTaggingItemScore :exec
UPDATE tagging_items
SET best_match_release_mbid = ?,
score = ?,
last_checked_at = CURRENT_TIMESTAMP
WHERE group_key = ?
`
type SetTaggingItemScoreParams struct {
BestMatchReleaseMbid sql.NullString
Score sql.NullFloat64
GroupKey string
}
func (q *Queries) SetTaggingItemScore(ctx context.Context, arg SetTaggingItemScoreParams) error {
_, err := q.db.ExecContext(ctx, setTaggingItemScore, arg.BestMatchReleaseMbid, arg.Score, arg.GroupKey)
return err
}
const setTaggingItemStatus = `-- name: SetTaggingItemStatus :exec
UPDATE tagging_items
SET status = ?,
last_checked_at = CURRENT_TIMESTAMP
WHERE group_key = ?
`
type SetTaggingItemStatusParams struct {
Status string
GroupKey string
}
func (q *Queries) SetTaggingItemStatus(ctx context.Context, arg SetTaggingItemStatusParams) error {
_, err := q.db.ExecContext(ctx, setTaggingItemStatus, arg.Status, arg.GroupKey)
return err
}
const upsertTaggingItemOnTrackAdd = `-- name: UpsertTaggingItemOnTrackAdd :exec
INSERT INTO tagging_items (
group_key, library_id, track_count,
album_name, album_artist, disc_number, status
)
VALUES (?, ?, 1, ?, ?, ?, 'pending')
ON CONFLICT(group_key) DO UPDATE SET
track_count = tagging_items.track_count + 1,
album_name = CASE
WHEN tagging_items.album_name = '' THEN excluded.album_name
ELSE tagging_items.album_name
END,
album_artist = CASE
WHEN tagging_items.album_artist = '' THEN excluded.album_artist
ELSE tagging_items.album_artist
END
`
type UpsertTaggingItemOnTrackAddParams struct {
GroupKey string
LibraryID int64
AlbumName string
AlbumArtist string
DiscNumber int64
}
func (q *Queries) UpsertTaggingItemOnTrackAdd(ctx context.Context, arg UpsertTaggingItemOnTrackAddParams) error {
_, err := q.db.ExecContext(ctx, upsertTaggingItemOnTrackAdd,
arg.GroupKey,
arg.LibraryID,
arg.AlbumName,
arg.AlbumArtist,
arg.DiscNumber,
)
return err
}
+443
View File
@@ -0,0 +1,443 @@
package database_test
import (
"strings"
"testing"
"yellowjacket/backend/autotag"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
)
// ---------------------------------------------------------------------------
// Migration 31: tag_status column
// ---------------------------------------------------------------------------
func TestMigration31_TagStatusDefaultAndCheck(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seedAF(t, db, "/music/a.mp3", 0, 0, "", "")
got := scalarString(t, db,
`SELECT tag_status FROM audio_files WHERE file_path = ?`,
"/music/a.mp3",
)
if got != "untagged" {
t.Errorf("default tag_status = %q, want %q", got, "untagged")
}
// CHECK constraint is applied inline with ALTER TABLE ADD COLUMN
// in migration 31, so both fresh and upgraded DBs enforce it.
_, err := db.ExecContext(
`UPDATE audio_files SET tag_status = 'bogus' WHERE file_path = ?`,
"/music/a.mp3",
)
if err == nil {
t.Error("expected CHECK constraint failure for invalid tag_status")
} else if !strings.Contains(err.Error(), "CHECK") &&
!strings.Contains(err.Error(), "constraint") {
t.Errorf("unexpected error: %v", err)
}
}
func TestMigration31_BackfillFromRecordingMBID(t *testing.T) {
t.Parallel()
// Migration 31 runs against a DB that already exists — NewTestDB
// creates a fresh DB and applies schemas + migrations. To
// exercise the backfill we seed audio_files with recordings whose
// mbid field varies, then re-run the same UPDATE the migration
// issues and verify each row lands on the expected status.
db := database.NewTestDB(t)
seedAF(t, db, "/music/no-mb.mp3", 0, 0, "Song A", "")
seedAF(t, db, "/music/empty-mb.mp3", 0, 0, "Song B", "")
seedAF(t, db, "/music/valid-mb.mp3", 0, 0, "Song C",
"11111111-2222-3333-4444-555555555555",
)
// Clear any status first so the backfill has work to do.
if _, err := db.ExecContext(
`UPDATE audio_files SET tag_status = 'untagged'`,
); err != nil {
t.Fatalf("reset tag_status: %v", err)
}
if _, err := db.ExecContext(`
UPDATE audio_files
SET tag_status = 'user_confirmed'
WHERE tag_status = 'untagged'
AND recording_id IN (
SELECT id FROM recordings
WHERE mbid IS NOT NULL AND mbid != ''
)
`); err != nil {
t.Fatalf("backfill: %v", err)
}
cases := map[string]string{
"/music/no-mb.mp3": "untagged",
"/music/empty-mb.mp3": "untagged",
"/music/valid-mb.mp3": "user_confirmed",
}
for path, want := range cases {
got := scalarString(t, db,
`SELECT tag_status FROM audio_files WHERE file_path = ?`, path,
)
if got != want {
t.Errorf("tag_status for %s = %q, want %q", path, got, want)
}
}
}
// ---------------------------------------------------------------------------
// Migration 32: tagging_items table + group_key column
// ---------------------------------------------------------------------------
func TestMigration32_TaggingItemsAndGroupKey(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
// The schema already has group_key and tagging_items. Simulate
// the migration backfill by inserting a couple of audio_files
// without group_key, then running the Go helper to set it, and
// verify the aggregate-into-tagging_items step produces one row
// per (group_key, library_id).
seedAF(t, db, "/music/Artist/Album/01.mp3", 0, 1, "T1", "")
seedAF(t, db, "/music/Artist/Album/02.mp3", 0, 1, "T2", "")
seedAF(t, db, "/music/Artist/Other/01.mp3", 0, 1, "T3", "")
// Clear any auto-populated group_key from CreateAudioFile.
if _, err := db.ExecContext(
`UPDATE audio_files SET group_key = ''`,
); err != nil {
t.Fatalf("reset group_key: %v", err)
}
// Run the same backfill logic inline.
rows, err := db.QueryContext(
`SELECT id, library_id, file_path FROM audio_files ORDER BY id`,
)
if err != nil {
t.Fatalf("select rows: %v", err)
}
type afRow struct {
id int64
libraryID int64
path string
}
var afs []afRow
for rows.Next() {
var r afRow
if scanErr := rows.Scan(&r.id, &r.libraryID, &r.path); scanErr != nil {
t.Fatalf("scan: %v", scanErr)
}
afs = append(afs, r)
}
_ = rows.Close()
// Album for first two files, Other for third — shared parent dirs
// produce shared group_keys.
for _, r := range afs {
key := autotag.GroupKey(r.libraryID, r.path, 0)
if _, err := db.ExecContext(
`UPDATE audio_files SET group_key = ? WHERE id = ?`,
key, r.id,
); err != nil {
t.Fatalf("set group_key: %v", err)
}
}
// Aggregate.
if _, err := db.ExecContext(`
INSERT INTO tagging_items (
group_key, library_id, track_count,
album_name, album_artist, disc_number, status
)
SELECT
af.group_key, af.library_id, COUNT(*),
'', '', 0,
CASE WHEN SUM(CASE WHEN af.tag_status = 'user_confirmed' THEN 0 ELSE 1 END) = 0
THEN 'confirmed' ELSE 'pending' END
FROM audio_files af
WHERE af.group_key != ''
GROUP BY af.group_key, af.library_id
ON CONFLICT(group_key) DO NOTHING
`); err != nil {
t.Fatalf("aggregate: %v", err)
}
got := scalarInt(t, db, `SELECT COUNT(*) FROM tagging_items`)
if got != 2 {
t.Errorf("tagging_items count = %d, want 2", got)
}
// The 2-track Album group should carry track_count = 2.
albumKey := autotag.GroupKey(0, "/music/Artist/Album/01.mp3", 0)
count := scalarInt(t, db,
`SELECT track_count FROM tagging_items WHERE group_key = ?`, albumKey,
)
if count != 2 {
t.Errorf("album track_count = %d, want 2", count)
}
}
// ---------------------------------------------------------------------------
// 008.4 — sqlc queries, pagination, and partial-index usage
// ---------------------------------------------------------------------------
func TestTaggingItems_ListPendingAndCount(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seedTaggingItem(t, db, "g1", 0, "Album A", "Artist A", 2, "pending")
seedTaggingItem(t, db, "g2", 0, "Album B", "Artist B", 1, "pending")
seedTaggingItem(t, db, "g3", 0, "Album C", "Artist C", 3, "confirmed")
count, err := db.Queries.CountPendingTaggingItems(db.Ctx, 0)
if err != nil {
t.Fatalf("count: %v", err)
}
if count != 2 { //nolint:mnd
t.Errorf("pending count = %d, want 2", count)
}
items, err := db.Queries.ListPendingTaggingItemsAlphabetical(
db.Ctx,
sqlcgen.ListPendingTaggingItemsAlphabeticalParams{
LibraryID: 0,
StatusFilter: "pending",
RowLimit: 50,
RowOffset: 0,
},
)
if err != nil {
t.Fatalf("list alphabetical: %v", err)
}
if len(items) != 2 { //nolint:mnd
t.Fatalf("list len = %d, want 2", len(items))
}
if items[0].AlbumArtist != "Artist A" || items[1].AlbumArtist != "Artist B" {
t.Errorf(
"unexpected order: %q, %q",
items[0].AlbumArtist, items[1].AlbumArtist,
)
}
}
func TestCountPendingTaggingItems_UsesPartialIndex(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seedTaggingItem(t, db, "g1", 0, "Album A", "Artist A", 2, "pending")
rows, err := db.QueryContext(`
EXPLAIN QUERY PLAN
SELECT COUNT(*) FROM tagging_items
WHERE status = 'pending'
AND (CAST(0 AS INTEGER) = 0 OR library_id = 0)
`)
if err != nil {
t.Fatalf("explain: %v", err)
}
defer func() { _ = rows.Close() }()
var plan strings.Builder
for rows.Next() {
var id, parent, notused int
var detail string
if scanErr := rows.Scan(&id, &parent, &notused, &detail); scanErr != nil {
t.Fatalf("scan: %v", scanErr)
}
plan.WriteString(detail)
plan.WriteString("\n")
}
// Must show the partial index is being used — if a future schema
// change drops or renames it, this assertion fires loudly.
if !strings.Contains(plan.String(), "idx_tagging_items_status_pending") {
t.Errorf(
"badge query plan does not use idx_tagging_items_status_pending:\n%s",
plan.String(),
)
}
}
func TestGetTaggingItemAndListAudioFilesInGroup(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
seedTaggingItem(t, db, "g1", 0, "Album A", "Artist A", 2, "pending")
// Seed two audio files pointing at the same group.
id1 := seedAF(t, db, "/music/A/01.mp3", 0, 0, "T1", "")
id2 := seedAF(t, db, "/music/A/02.mp3", 0, 0, "T2", "")
if _, err := db.ExecContext(
`UPDATE audio_files SET group_key = 'g1' WHERE id IN (?, ?)`,
id1, id2,
); err != nil {
t.Fatalf("bind group_key: %v", err)
}
item, err := db.Queries.GetTaggingItem(db.Ctx, "g1")
if err != nil {
t.Fatalf("get: %v", err)
}
if item.AlbumName != "Album A" {
t.Errorf("album_name = %q", item.AlbumName)
}
files, err := db.Queries.ListAudioFilesInTaggingGroup(db.Ctx, "g1")
if err != nil {
t.Fatalf("list files: %v", err)
}
if len(files) != 2 { //nolint:mnd
t.Errorf("files in group = %d, want 2", len(files))
}
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
// seedAF inserts a minimal recording + audio_files pair and returns
// the new audio_files id. All FK-satisfying rows (artist_credit,
// recordings, file_types[0]) are created inline.
func seedAF(
t *testing.T,
db *database.DB,
filePath string,
libraryID, discNumber int64,
recordingName, recordingMBID string,
) int64 {
t.Helper()
ac, err := db.Queries.UpsertArtistCredit(db.Ctx, "Test Artist")
if err != nil {
t.Fatalf("upsert artist credit: %v", err)
}
rec, err := db.Queries.CreateRecordingFull(
db.Ctx,
sqlcgen.CreateRecordingFullParams{
Name: recordingName,
ArtistCreditID: ac.ID,
},
)
if err != nil {
t.Fatalf("create recording: %v", err)
}
if recordingMBID != "" {
if _, err := db.ExecContext(
`UPDATE recordings SET mbid = ? WHERE id = ?`,
recordingMBID, rec.ID,
); err != nil {
t.Fatalf("set mbid: %v", err)
}
}
af, err := db.Queries.CreateAudioFile(
db.Ctx,
sqlcgen.CreateAudioFileParams{
FilePath: filePath,
LengthMilliseconds: 1000,
FileTypeID: 0,
RecordingID: rec.ID,
Basename: filePath,
LibraryID: libraryID,
},
)
if err != nil {
t.Fatalf("create audio file: %v", err)
}
_ = discNumber // reserved for callers that want specific disc values
return af.ID
}
func seedTaggingItem(
t *testing.T,
db *database.DB,
groupKey string,
libraryID int64,
album, artist string,
trackCount int,
status string,
) {
t.Helper()
if _, err := db.ExecContext(`
INSERT INTO tagging_items (
group_key, library_id, track_count,
album_name, album_artist, disc_number, status
) VALUES (?, ?, ?, ?, ?, 0, ?)
`, groupKey, libraryID, trackCount, album, artist, status); err != nil {
t.Fatalf("seed tagging_item: %v", err)
}
}
func scalarString(t *testing.T, db *database.DB, query string, args ...any) string {
t.Helper()
rows, err := db.QueryContext(query, args...)
if err != nil {
t.Fatalf("query %q: %v", query, err)
}
defer func() { _ = rows.Close() }()
var got string
if rows.Next() {
if scanErr := rows.Scan(&got); scanErr != nil {
t.Fatalf("scan %q: %v", query, scanErr)
}
}
return got
}
func scalarInt(t *testing.T, db *database.DB, query string, args ...any) int64 {
t.Helper()
rows, err := db.QueryContext(query, args...)
if err != nil {
t.Fatalf("query %q: %v", query, err)
}
defer func() { _ = rows.Close() }()
var got int64
if rows.Next() {
if scanErr := rows.Scan(&got); scanErr != nil {
t.Fatalf("scan %q: %v", query, scanErr)
}
}
return got
}
+13
View File
@@ -74,6 +74,19 @@ const (
BatchWriteProgress = "BatchWriteProgress"
)
// Autotag apply events — emitted while an async ApplyAsync job is in flight so the review UI can render per-folder progress.
const (
AutotagApplyStarted = "AutotagApplyStarted" // {groupKey: string, total: int}
AutotagApplyProgress = "AutotagApplyProgress" // {groupKey, current, total, succeeded, failed}
AutotagApplyFinished = "AutotagApplyFinished" // {groupKey, succeeded, failed, error}
)
// Autotag prefetch events — emitted by the background worker that scores pending tagging items so sidebar pills populate without the user having to open each folder.
const (
AutotagPrefetchProgress = "AutotagPrefetchProgress" // {processed, total} — debounce on frontend
AutotagPrefetchFinished = "AutotagPrefetchFinished" // {processed, total}
)
// Explore / search index events.
const (
IndexStatusChanged = "IndexStatusChanged"
+137
View File
@@ -0,0 +1,137 @@
package explore
import (
"context"
"yellowjacket/backend/autotag"
)
// AutotagClient adapts *MusicBrainzClient to autotag.MBClient.
// Lives in the explore package so autotag stays free of
// explore-internal types — callers in app wiring construct one via
// NewAutotagClient and hand it to the scorer.
type AutotagClient struct {
inner *MusicBrainzClient
}
// NewAutotagClient wraps a MusicBrainzClient for use by the
// autotag scorer.
func NewAutotagClient(inner *MusicBrainzClient) *AutotagClient {
return &AutotagClient{inner: inner}
}
// SearchReleaseGroups delegates to the wrapped client and projects
// hits into autotag's minimal shape.
func (c *AutotagClient) SearchReleaseGroups(
ctx context.Context, query string, limit int,
) ([]autotag.MBReleaseGroupHit, int, error) {
hits, total, err := c.inner.SearchReleaseGroups(ctx, query, limit)
if err != nil {
return nil, 0, err
}
out := make([]autotag.MBReleaseGroupHit, 0, len(hits))
for _, h := range hits {
out = append(out, autotag.MBReleaseGroupHit{
MBID: h.MBID,
Title: h.Title,
ArtistCredit: h.ArtistCredit,
FirstDate: h.FirstReleaseDate,
PrimaryType: h.PrimaryType,
})
}
return out, total, nil
}
// BrowseReleases delegates to the wrapped client and projects each
// release (and its tracks) into autotag's shape. Length is
// millisecond-aligned to match local audio_files.
func (c *AutotagClient) BrowseReleases(
ctx context.Context, releaseGroupMBID string,
) ([]autotag.MBRelease, error) {
releases, err := c.inner.BrowseReleases(ctx, releaseGroupMBID)
if err != nil {
return nil, err
}
out := make([]autotag.MBRelease, 0, len(releases))
for _, rel := range releases {
out = append(out, exploreToAutotagRelease(rel))
}
return out, nil
}
// LookupRelease fetches a single release by MBID and projects it
// into autotag's shape.
func (c *AutotagClient) LookupRelease(
ctx context.Context, releaseMBID string,
) (autotag.MBRelease, error) {
rel, err := c.inner.LookupRelease(ctx, releaseMBID)
if err != nil {
return autotag.MBRelease{}, err
}
return exploreToAutotagRelease(*rel), nil
}
// LookupReleaseGroup fetches a single release group by MBID and
// projects it into autotag's hit shape.
func (c *AutotagClient) LookupReleaseGroup(
ctx context.Context, releaseGroupMBID string,
) (autotag.MBReleaseGroupHit, error) {
rg, err := c.inner.LookupReleaseGroup(ctx, releaseGroupMBID)
if err != nil {
return autotag.MBReleaseGroupHit{}, err
}
return autotag.MBReleaseGroupHit{
MBID: rg.MBID,
Title: rg.Title,
ArtistCredit: rg.ArtistCredit,
FirstDate: rg.FirstReleaseDate,
PrimaryType: rg.PrimaryType,
}, nil
}
// LookupArtist returns the artist's sort name when available,
// falling back to the display name. Used by the resolver when
// constructing Lucene-style fallback queries.
func (c *AutotagClient) LookupArtist(
ctx context.Context, mbid string,
) (string, error) {
a, err := c.inner.LookupArtist(ctx, mbid)
if err != nil {
return "", err
}
if a.SortName != "" {
return a.SortName, nil
}
return a.Name, nil
}
func exploreToAutotagRelease(rel MBRelease) autotag.MBRelease {
tracks := make([]autotag.CandidateTrack, 0, len(rel.Tracks))
for _, t := range rel.Tracks {
tracks = append(tracks, autotag.CandidateTrack{
Position: t.Position,
DiscNumber: t.DiscNumber,
Title: t.Title,
LengthMillis: int64(t.Length),
MBID: t.MBID,
})
}
return autotag.MBRelease{
MBID: rel.MBID,
Title: rel.Title,
Date: rel.Date,
Country: rel.Country,
Status: rel.Status,
ArtistCredit: rel.ArtistCredit,
Tracks: tracks,
}
}
+152
View File
@@ -0,0 +1,152 @@
package explore
import (
"bytes"
"context"
"errors"
"fmt"
"image"
_ "image/jpeg" // Register decoder.
_ "image/png" // Register decoder.
"io"
"log/slog"
"net/http"
"os"
"time"
"yellowjacket/backend/metadata"
)
// minCoverArtDimensionPx is the minimum size on the shortest side
// of a cover-art image the autotagger will embed. Below this the
// image is considered too low-res for desktop/mobile display.
// REVIEW-05 in the 010 plan pins this; 012 may surface it as a
// config option.
const minCoverArtDimensionPx = 500
// caaFetchTimeout bounds the single CAA GET. CAA redirects to
// archive.org, which can be slow but shouldn't stall apply for
// minutes.
const caaFetchTimeout = 30 * time.Second
// errCAANot2xx signals a CAA response that wasn't 200 or 404.
var errCAANot2xx = errors.New("autotag cover art: unexpected CAA status")
// AutotagCoverArt implements autotag.CoverArtEmbedder against the
// Cover Art Archive. Rule: never replace existing art; only embed
// when the file has none AND CAA returns an image ≥500 px on the
// shortest side.
type AutotagCoverArt struct {
limiter *RateLimiter
logger *slog.Logger
httpCli *http.Client
}
// NewAutotagCoverArt wires up the embedder with the shared CAA
// limiter. httpClient may be nil — a default 30 s client is used.
func NewAutotagCoverArt(limiter *RateLimiter, logger *slog.Logger) *AutotagCoverArt {
return &AutotagCoverArt{
limiter: limiter,
logger: logger,
httpCli: &http.Client{Timeout: caaFetchTimeout},
}
}
// FetchArt is the network half of autotag.CoverArtEmbedder. Hits
// CAA for the release group, validates the result is at least
// 500 px on the shortest side, and returns the raw bytes ready to
// embed. Returns (nil, nil) when CAA has nothing or the result is
// below the minimum size; (nil, err) when the network or decode
// failed. Caller is expected to invoke this once per album and
// reuse the bytes across every track that lacks embedded art.
func (c *AutotagCoverArt) FetchArt(
ctx context.Context, releaseGroupMBID string,
) ([]byte, error) {
if releaseGroupMBID == "" {
return nil, nil
}
if err := c.limiter.Wait(ctx); err != nil {
return nil, fmt.Errorf("wait for CAA limiter: %w", err)
}
url := CoverArtGroupURLSize(releaseGroupMBID, minCoverArtDimensionPx*2) //nolint:mnd
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build CAA request: %w", err)
}
resp, err := c.httpCli.Do(req)
if err != nil {
return nil, fmt.Errorf("CAA GET: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: %d for %s", errCAANot2xx, resp.StatusCode, url)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read CAA body: %w", err)
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("decode CAA image: %w", err)
}
shortest := cfg.Width
if cfg.Height < shortest {
shortest = cfg.Height
}
if shortest < minCoverArtDimensionPx {
c.logger.Debug(
"cover art: CAA result below 500px, skipping",
"width", cfg.Width, "height", cfg.Height,
"release_group_mbid", releaseGroupMBID,
)
return nil, nil
}
return body, nil
}
// HasEmbeddedArt reports whether the local audio file already
// carries embedded picture data. The autotag pipeline calls this
// per track to decide whether to merge the album's CAA art into
// that track's changes — never replacing existing art is the
// invariant. If metadata extraction fails the function returns
// false (safer default: fall through to "no art present, may
// embed" — the writer still won't overwrite anything because the
// tag-level diff is built from this signal).
func (c *AutotagCoverArt) HasEmbeddedArt(filePath string) bool {
if _, err := os.Stat(filePath); err != nil {
c.logger.Debug(
"cover art: stat for embedded-art probe failed",
"path", filePath, "err", err,
)
return false
}
tags, _, _, _, err := metadata.ExtractAllMetadata(filePath, true)
if err != nil {
c.logger.Debug(
"cover art: extract for embedded-art probe failed",
"path", filePath, "err", err,
)
return false
}
return tags != nil && tags.Picture != nil && len(tags.Picture.Data) > 0
}
+64 -124
View File
@@ -9,7 +9,6 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -34,24 +33,34 @@ const (
)
// CoverArtProxy fetches and caches cover art thumbnails locally.
// It checks three sources in order:
// 1. Local library cover art (instant, matched by album+artist name)
// 2. Disk cache from a previous CAA fetch (instant)
// 3. Cover Art Archive network fetch (slow, cached to disk)
// It checks two sources in order:
// 1. Disk cache from a previous CAA fetch (instant)
// 2. Cover Art Archive network fetch (slow, cached to disk)
//
// The proxy used to also consult the local library's cover_art
// table by album/artist name, but that path conflated externally-
// fetched art with audio-file embedded ID3 art (the cover_art
// table writes both with is_embedded=true, so they're
// indistinguishable downstream). For autotag review and explore
// browsing we want the canonical CAA cover, not whatever bytes
// happen to be tagged on a user's local file — so the library
// lookup was removed. The user's own library views (cover grid,
// album page) still display embedded art via a separate code path
// that reads cover_art.file_path directly, which is fine because
// that's their library, not Explore's view of MB.
type CoverArtProxy struct {
db *database.DB
cacheDir string
client *http.Client
limiter *RateLimiter
mu sync.Mutex // serializes disk writes
libOnce sync.Once
libIndex map[string]string // "album\x00artist" → cover art file path
mu sync.Mutex // serializes disk writes
}
// NewCoverArtProxy creates a proxy that checks the local library
// first and caches CAA thumbnails under the user data directory.
func NewCoverArtProxy(db *database.DB, limiter *RateLimiter) *CoverArtProxy {
// NewCoverArtProxy creates a proxy that caches CAA thumbnails
// under the user data directory. The db parameter is accepted
// for API stability but is no longer read; future cover-art
// logic that needs DB access can wire it back up.
func NewCoverArtProxy(_ *database.DB, limiter *RateLimiter) *CoverArtProxy {
dir := ""
dataDir, err := system.GetUserDataDirPath()
@@ -61,18 +70,18 @@ func NewCoverArtProxy(db *database.DB, limiter *RateLimiter) *CoverArtProxy {
}
return &CoverArtProxy{
db: db,
cacheDir: dir,
client: &http.Client{Timeout: thumbnailTimeout},
limiter: limiter,
}
}
// GetThumbnail returns a base64-encoded JPEG data URL for the given
// release group. Checks local library art first (by name match),
// GetThumbnail returns a base64 data URL for an album's cover art.
// Checks local library art first, then disk cache, then fetches from CAA.
// Returns "" on failure.
// GetThumbnail returns a base64-encoded JPEG data URL for the
// given release group. Checks the disk cache first; falls back
// to a CAA network fetch. Returns "" on failure. The albumName
// / artistName args are accepted for API stability and ignored
// (they used to drive a library-by-name lookup; see the proxy
// type comment for why that was removed).
//
// The mbid argument MUST be a release group MBID. Track-level cover
// art (where you only have a release MBID) should be resolved by
@@ -80,7 +89,6 @@ func NewCoverArtProxy(db *database.DB, limiter *RateLimiter) *CoverArtProxy {
func (p *CoverArtProxy) GetThumbnail(
releaseGroupMBID, albumName, artistName string,
) string {
// Source 1+2: local library art + disk cache (instant).
if cached := p.GetThumbnailCached(releaseGroupMBID, albumName, artistName); cached != "" {
return cached
}
@@ -106,67 +114,70 @@ func (p *CoverArtProxy) GetThumbnail(
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(data)
}
// GetThumbnailCached checks only local library art and disk cache.
// Returns "" if not cached — does NOT fetch from the network.
// GetThumbnailCached returns the disk-cached cover art for the
// release group, or "" if it isn't on disk. Does NOT fetch from
// the network. albumName/artistName accepted for API stability
// and ignored — see the proxy type comment.
func (p *CoverArtProxy) GetThumbnailCached(
releaseGroupMBID, albumName, artistName string,
releaseGroupMBID, _, _ string,
) string {
// Source 1: local library cover art (instant).
if albumName != "" {
if dataURL := p.libraryArt(albumName, artistName); dataURL != "" {
return dataURL
}
}
if p.cacheDir == "" || releaseGroupMBID == "" {
return ""
}
// Source 2: disk cache from previous CAA fetch (instant).
return p.readCache(releaseGroupMBID)
}
// GetTrackThumbnail returns cover art for a track. Tries, in order:
// 1. Local library art by album/artist name.
// 2. Disk cache for the release group MBID (shared with discography).
// 3. Disk cache for the release MBID (per-track fallback).
// 4. CAA network fetch on the release group (populates RG cache).
// 5. CAA network fetch on the release (populates release cache).
// 1. Disk cache for the release group MBID (shared with discography).
// 2. Disk cache for the release MBID (per-track fallback).
// 3. CAA network fetch on the release group (populates RG cache).
// 4. CAA network fetch on the release (populates release cache).
//
// Either or both MBIDs may be empty — whichever is present is tried.
// Release group is preferred because it shares the cache with the
// discography and top-releases sections; release is the fallback for
// tracks whose caa_release_mbid doesn't resolve to a known RG in the
// index (e.g. the track is on a release not fetched for that artist).
//
// The albumName/artistName args are accepted for API stability and
// ignored — see the proxy type comment for why the library-by-name
// step was removed.
func (p *CoverArtProxy) GetTrackThumbnail(
releaseMBID, releaseGroupMBID, albumName, artistName string,
releaseMBID, releaseGroupMBID, _, _ string,
) string {
// Source 1: local library art (instant).
if albumName != "" {
if dataURL := p.libraryArt(albumName, artistName); dataURL != "" {
return dataURL
}
}
return p.GetCandidateThumbnail(releaseMBID, releaseGroupMBID)
}
// GetCandidateThumbnail is the canonical CAA-only lookup: disk
// cache for the release group, disk cache for the release, then
// CAA network on each in turn. Used everywhere the user is
// browsing or reviewing albums that aren't *their* library copy
// (autotag review, explore) — for those views we want the
// canonical CAA art, not whatever ID3 bytes happen to be tagged
// on a local file. Returns "" when no art is available.
func (p *CoverArtProxy) GetCandidateThumbnail(
releaseMBID, releaseGroupMBID string,
) string {
if p.cacheDir == "" {
return ""
}
// Source 2: disk cache for release group (shared with discography).
// Disk cache for release group (shared with discography).
if releaseGroupMBID != "" {
if cached := p.readCache(releaseGroupMBID); cached != "" {
return cached
}
}
// Source 3: disk cache for release (per-track fallback).
// Disk cache for release (per-track fallback).
if releaseMBID != "" {
if cached := p.readCache(releaseMBID); cached != "" {
return cached
}
}
// Source 4: CAA network fetch on release group.
// Network fetch on release group.
if releaseGroupMBID != "" {
url := CoverArtGroupURL(releaseGroupMBID)
data, cacheable, err := p.fetch(url)
@@ -178,13 +189,11 @@ func (p *CoverArtProxy) GetTrackThumbnail(
}
if cacheable {
// Mark RG miss so we don't re-fetch it, but fall through
// to the release-level fallback.
p.writeCache(releaseGroupMBID, nil)
}
}
// Source 5: CAA network fetch on release (fallback).
// Network fetch on release (fallback).
if releaseMBID != "" {
url := CoverArtURL(releaseMBID)
data, cacheable, err := p.fetch(url)
@@ -205,18 +214,14 @@ func (p *CoverArtProxy) GetTrackThumbnail(
return ""
}
// GetTrackThumbnailCached returns a cached track thumbnail without
// hitting the network. Tries library art, then RG cache, then
// release cache. Returns "" if nothing is cached.
// GetTrackThumbnailCached returns the disk-cached track thumbnail
// (RG MBID first, then release MBID). Returns "" when nothing is
// cached. Does NOT fetch from the network. albumName/artistName
// accepted for API stability and ignored — see the proxy type
// comment.
func (p *CoverArtProxy) GetTrackThumbnailCached(
releaseMBID, releaseGroupMBID, albumName, artistName string,
releaseMBID, releaseGroupMBID, _, _ string,
) string {
if albumName != "" {
if dataURL := p.libraryArt(albumName, artistName); dataURL != "" {
return dataURL
}
}
if p.cacheDir == "" {
return ""
}
@@ -237,72 +242,7 @@ func (p *CoverArtProxy) GetTrackThumbnailCached(
}
// ---------------------------------------------------------------------------
// Source 1: local library art
// ---------------------------------------------------------------------------
// libraryArt returns a base64 data URL for the album if it exists
// in the local music library. Matched by lowercased album name +
// artist name.
func (p *CoverArtProxy) libraryArt(albumName, artistName string) string {
p.libOnce.Do(p.buildLibraryIndex)
key := libraryArtKey(albumName, artistName)
path, ok := p.libIndex[key]
if !ok || path == "" {
return ""
}
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
return ""
}
mime := "image/jpeg"
if strings.HasSuffix(strings.ToLower(path), ".png") {
mime = "image/png"
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)
}
func (p *CoverArtProxy) buildLibraryIndex() {
p.libIndex = make(map[string]string)
if p.db == nil {
return
}
rows, err := p.db.QueryContext(`
SELECT rg.name, a.name, ca.file_path
FROM release_groups rg
JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
JOIN artists a ON a.id = aca.artist_id
LEFT JOIN cover_art ca ON ca.id = rg.cover_art_id
WHERE ca.file_path IS NOT NULL AND ca.file_path != ''
`)
if err != nil {
return
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var album, artist, path string
if err := rows.Scan(&album, &artist, &path); err == nil {
key := libraryArtKey(album, artist)
p.libIndex[key] = path
}
}
}
func libraryArtKey(album, artist string) string {
return strings.ToLower(album) + "\x00" + strings.ToLower(artist)
}
// ---------------------------------------------------------------------------
// Source 2+3: CAA disk cache and network fetch
// CAA disk cache and network fetch
// ---------------------------------------------------------------------------
func (p *CoverArtProxy) fetch(url string) ([]byte, bool, error) {
+245 -85
View File
@@ -18,16 +18,17 @@ import (
// response cache. Its exported methods form the binding surface
// that the frontend calls via generated TypeScript stubs.
type Service struct {
mb *MusicBrainzClient
lb *ListenBrainzClient
cache *Cache
index *SearchIndex
artProxy *CoverArtProxy
artistImg *ArtistImageProvider
libMBID *LibraryMBIDIndex
db *database.DB
logger *slog.Logger
ctx context.Context
mb *MusicBrainzClient
lb *ListenBrainzClient
cache *Cache
index *SearchIndex
artProxy *CoverArtProxy
artistImg *ArtistImageProvider
libMBID *LibraryMBIDIndex
caaLimiter *RateLimiter
db *database.DB
logger *slog.Logger
ctx context.Context
}
// NewExploreService creates a Service backed by the given
@@ -57,24 +58,38 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
)
index := NewSearchIndex(db, lb, artistImg, logger.WithGroup("search-index"))
index.MarkReadyIfPopulated() // make index queryable immediately if data exists
libMBID := NewLibraryMBIDIndex(db)
logger.Info("explore service created")
return &Service{
mb: mb,
lb: lb,
cache: cache,
index: index,
artProxy: artProxy,
artistImg: artistImg,
libMBID: libMBID,
db: db,
logger: logger,
ctx: context.Background(),
mb: mb,
lb: lb,
cache: cache,
index: index,
artProxy: artProxy,
artistImg: artistImg,
libMBID: libMBID,
caaLimiter: caaLimiter,
db: db,
logger: logger,
ctx: context.Background(),
}
}
// MusicBrainz returns the shared cached MB client so other services
// (e.g. autotag) can reuse it without spinning up a second limiter.
func (e *Service) MusicBrainz() *MusicBrainzClient {
return e.mb
}
// CAALimiter returns the shared Cover Art Archive rate limiter.
// Consumers must respect it for any fresh CAA HTTP GETs.
func (e *Service) CAALimiter() *RateLimiter {
return e.caaLimiter
}
// SetContext injects the Wails runtime context. Called from
// OnStartup after the Wails runtime is initialised.
func (e *Service) SetContext(ctx context.Context) {
@@ -137,18 +152,21 @@ func (e *Service) InvalidateIndexDiscographies() {
// SearchArtists queries MusicBrainz for artists matching the query.
func (e *Service) SearchArtists(query string) ([]MBArtist, error) {
artists, _, err := e.mb.SearchArtists(e.ctx, query, mbSearchLimit)
return artists, err
}
// SearchReleaseGroups queries MusicBrainz for release groups matching the query.
func (e *Service) SearchReleaseGroups(query string) ([]MBReleaseGroup, error) {
rgs, _, err := e.mb.SearchReleaseGroups(e.ctx, query, mbSearchLimit)
return rgs, err
}
// SearchRecordings queries MusicBrainz for recordings matching the query.
func (e *Service) SearchRecordings(query string) ([]MBRecording, error) {
recs, _, err := e.mb.SearchRecordings(e.ctx, query, mbSearchLimit)
return recs, err
}
@@ -173,6 +191,7 @@ func (e *Service) SearchLocal(query string) *MBSearchResult {
filtered = append(filtered, a)
}
}
result.Artists = filtered
}
@@ -333,10 +352,10 @@ func (e *Service) BrowseReleaseGroups(artistMBID string) ([]MBReleaseGroup, erro
// of release groups returned from MB browse-by-artist. MB browse
// doesn't echo back the artist credit on each item (since the artist
// is the query parameter), so we need to find a name from somewhere:
// 1. First non-empty ArtistCredit on any release group
// 2. The local explore_index (if the artist was previously indexed)
// 3. A LookupArtist call to MB (last resort)
// 4. The MBID itself (worst case fallback)
// 1. First non-empty ArtistCredit on any release group
// 2. The local explore_index (if the artist was previously indexed)
// 3. A LookupArtist call to MB (last resort)
// 4. The MBID itself (worst case fallback)
func (e *Service) resolveArtistName(artistMBID string, rgs []MBReleaseGroup) string {
// Try first non-empty ArtistCredit from the release groups.
for _, rg := range rgs {
@@ -346,12 +365,19 @@ func (e *Service) resolveArtistName(artistMBID string, rgs []MBReleaseGroup) str
}
// Check the index for a previously-indexed artist row.
if indexed := e.index.LookupArtistByMBID(artistMBID); indexed != nil && indexed.Title != "" && indexed.Title != artistMBID {
if indexed := e.index.LookupArtistByMBID(
artistMBID,
); indexed != nil && indexed.Title != "" &&
indexed.Title != artistMBID {
return indexed.Title
}
// Last resort: hit MB lookup.
if artist, err := e.mb.LookupArtist(e.ctx, artistMBID); err == nil && artist != nil && artist.Name != "" {
if artist, err := e.mb.LookupArtist(
e.ctx,
artistMBID,
); err == nil && artist != nil &&
artist.Name != "" {
return artist.Name
}
@@ -370,6 +396,7 @@ func (e *Service) BrowseReleases(releaseGroupMBID string) ([]MBRelease, error) {
// InLibrary flag on each track so the tracklist renderer can
// show the library-status indicator without a per-track roundtrip.
var trackMBIDs []string
for _, rel := range releases {
for _, t := range rel.Tracks {
if t.MBID != "" {
@@ -572,10 +599,20 @@ func (e *Service) GetThumbnail(releaseGroupMBID, albumName, artistName string) s
// discography cache; falls back to the release-level CAA endpoint
// when the RG isn't known — useful when the track's preferred CAA
// release doesn't belong to any RG currently in the index.
func (e *Service) GetTrackThumbnail(releaseMBID, releaseGroupMBID, albumName, artistName string) string {
func (e *Service) GetTrackThumbnail(
releaseMBID, releaseGroupMBID, albumName, artistName string,
) string {
return e.artProxy.GetTrackThumbnail(releaseMBID, releaseGroupMBID, albumName, artistName)
}
// GetCandidateThumbnail returns CAA-only cover art for an autotag
// candidate, skipping the library-by-name index so embedded ID3
// art on the user's existing files doesn't pollute the candidate
// preview. Disk cache → network on RG → network on release.
func (e *Service) GetCandidateThumbnail(releaseMBID, releaseGroupMBID string) string {
return e.artProxy.GetCandidateThumbnail(releaseMBID, releaseGroupMBID)
}
// TrackThumbnailRequest is a single item in a batch track thumbnail
// request. Either ReleaseMBID or ReleaseGroupMBID may be empty;
// the proxy tries whichever is present.
@@ -663,6 +700,7 @@ func (e *Service) GetArtistImageCached(artistMBID string) string {
// image is cached.
func (e *Service) GetArtistImageCachedPath(artistMBID string) string {
_, medium, _, _ := e.artistImg.GetImageURLs(artistMBID)
return medium
}
@@ -703,7 +741,10 @@ func (e *Service) GetPopularityBatch(mbids []string) map[string]PersonalizationR
// Include entries that have library/similar flags but no popularity.
for mbid := range batch.InLibrary {
if _, ok := out[mbid]; !ok {
out[mbid] = PersonalizationResult{InLibrary: true, SimilarityScore: batch.SimilarityScores[mbid]}
out[mbid] = PersonalizationResult{
InLibrary: true,
SimilarityScore: batch.SimilarityScores[mbid],
}
}
}
@@ -928,13 +969,21 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
p1Dur := time.Since(p1Start)
e.logger.Info("search phase 1 complete (MB)",
"query", query,
"artists", len(result.Artists),
"releaseGroups", len(result.ReleaseGroups),
"recordings", len(result.Recordings),
"expanded", len(result.Artists) > mbSearchLimit || len(result.ReleaseGroups) > mbSearchLimit || len(result.Recordings) > mbSearchLimit,
"elapsed", p1Dur.Round(time.Millisecond),
e.logger.Info(
"search phase 1 complete (MB)",
"query",
query,
"artists",
len(result.Artists),
"releaseGroups",
len(result.ReleaseGroups),
"recordings",
len(result.Recordings),
"expanded",
len(result.Artists) > mbSearchLimit || len(result.ReleaseGroups) > mbSearchLimit ||
len(result.Recordings) > mbSearchLimit,
"elapsed",
p1Dur.Round(time.Millisecond),
)
// Phases 2+3: when the index is ready, use cached popularity
@@ -975,6 +1024,7 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
// ordering for result sets where MB gave every candidate
// the same text relevance score.
var missingPop []string
for _, mbid := range artistMBIDs {
if artistPop[mbid] <= 0 {
missingPop = append(missingPop, mbid)
@@ -985,6 +1035,7 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
popCtx, popCancel := context.WithTimeout(e.ctx, searchSlowPathTimeout)
pop, err := e.lb.ArtistPopularity(popCtx, missingPop)
popCancel()
if err == nil && pop != nil {
@@ -1002,6 +1053,7 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
// so a hung LB server doesn't stall the search.
popCtx, popCancel := context.WithTimeout(e.ctx, 2*time.Second)
pop, _ := e.lb.ArtistPopularity(popCtx, artistMBIDs)
popCancel()
if pop != nil {
@@ -1045,12 +1097,14 @@ func (e *Service) Search(query string) (*MBSearchResult, error) {
// Leg 1: LB popularity for RGs and recordings.
go func() {
defer wgSlow.Done()
e.boostWithPopularityRGsAndRecs(&result)
}()
// Leg 2: cross-reference artist discographies.
go func() {
defer wgSlow.Done()
if slowCtx.Err() == nil {
e.crossReferenceAlbums(slowCtx, query, &result)
}
@@ -1307,9 +1361,11 @@ func mergeIndexHits(result *MBSearchResult, hits []SearchIndexResult) {
}
// Collect new entries from index that MB didn't return.
var newArtists []MBArtist
var newRGs []MBReleaseGroup
var newRecs []MBRecording
var (
newArtists []MBArtist
newRGs []MBReleaseGroup
newRecs []MBRecording
)
for _, h := range hits {
switch h.EntityType {
@@ -1318,18 +1374,18 @@ func mergeIndexHits(result *MBSearchResult, hits []SearchIndexResult) {
score := int(float64(scalePopularity(h.Popularity)) * 0.5)
newArtists = append(newArtists, MBArtist{
MBID: h.MBID,
Name: h.Title,
Type: h.ArtistType,
Country: h.Country,
MBID: h.MBID,
Name: h.Title,
Type: h.ArtistType,
Country: h.Country,
Disambiguation: h.Disambiguation,
SortName: h.SortName,
Score: score,
HasPopularity: h.Popularity > 0,
Popularity: h.Popularity,
ListenerCount: h.ListenerCount,
InLibrary: h.InLibrary || h.LocalArtistID > 0,
LocalID: h.LocalArtistID,
SortName: h.SortName,
Score: score,
HasPopularity: h.Popularity > 0,
Popularity: h.Popularity,
ListenerCount: h.ListenerCount,
InLibrary: h.InLibrary || h.LocalArtistID > 0,
LocalID: h.LocalArtistID,
})
artistMBIDs[h.MBID] = true
@@ -1446,7 +1502,8 @@ func filterAndCap(result *MBSearchResult) {
// Drop very-low-popularity results when the result
// set contains meaningfully popular alternatives.
if maxPop >= minPopularityFloor && a.HasPopularity && a.Popularity < minPopularityFloor {
if maxPop >= minPopularityFloor && a.HasPopularity &&
a.Popularity < minPopularityFloor {
continue
}
@@ -1507,7 +1564,7 @@ const (
mbSearchLimit = 25
// mbSearchMaxLimit caps the expanded fetch. MB's API maximum is 100.
mbSearchMaxLimit = 75
mbSearchMaxLimit = 75 //nolint:unused // referenced by deferred MB search rework
// indexSearchLimit is the number of results to fetch from the local
// popularity index (Phase 0). Larger than maxResults because
@@ -1600,6 +1657,8 @@ var mbSpecialPurposeArtists = map[string]bool{
// popularity data from the local search index. No API calls —
// just SQLite lookups. This is the fast path used when the index
// is ready.
//
//nolint:unused // referenced by deferred MB search rework.
func (e *Service) boostWithIndexPopularity(result *MBSearchResult) {
// Collect all MBIDs across all entity types.
allMBIDs := make([]string, 0,
@@ -1657,7 +1716,12 @@ func (e *Service) boostWithIndexPopularity(result *MBSearchResult) {
}
}
rerankReleaseGroupsPersonalized(result.ReleaseGroups, rgPop, batch.InLibrary, batch.SimilarityScores)
rerankReleaseGroupsPersonalized(
result.ReleaseGroups,
rgPop,
batch.InLibrary,
batch.SimilarityScores,
)
recPop := make(map[string]int, len(result.Recordings))
for i, r := range result.Recordings {
@@ -1713,20 +1777,24 @@ func (e *Service) boostWithIndexPopularityRGsAndRecs(result *MBSearchResult) {
// common path cache-only while correctness-critical cases get
// a ~1 round-trip to LB.
missingRecs := make([]string, 0)
for _, r := range result.Recordings {
if r.MBID == "" {
continue
}
if _, ok := batch.Popularity[r.MBID]; !ok {
missingRecs = append(missingRecs, r.MBID)
}
}
missingRGs := make([]string, 0)
for _, rg := range result.ReleaseGroups {
if rg.MBID == "" {
continue
}
if _, ok := batch.Popularity[rg.MBID]; !ok {
missingRGs = append(missingRGs, rg.MBID)
}
@@ -1936,7 +2004,9 @@ func (e *Service) boostNameMatches(query string, result *MBSearchResult) {
if len(result.Artists) > 1 {
for i := range result.Artists {
tier := nameMatchTier(q, strings.ToLower(result.Artists[i].Name))
result.Artists[i].Score = int(float64(result.Artists[i].Score) * (1.0 + tierBonus[tier]))
result.Artists[i].Score = int(
float64(result.Artists[i].Score) * (1.0 + tierBonus[tier]),
)
}
sort.SliceStable(result.Artists, func(i, j int) bool {
@@ -1954,7 +2024,9 @@ func (e *Service) boostNameMatches(query string, result *MBSearchResult) {
tier := rgMatchTier(q,
strings.ToLower(result.ReleaseGroups[i].Title),
strings.ToLower(result.ReleaseGroups[i].ArtistCredit))
result.ReleaseGroups[i].Score = int(float64(result.ReleaseGroups[i].Score) * (1.0 + rgTierBonus[tier]))
result.ReleaseGroups[i].Score = int(
float64(result.ReleaseGroups[i].Score) * (1.0 + rgTierBonus[tier]),
)
}
sort.SliceStable(result.ReleaseGroups, func(i, j int) bool {
@@ -2065,11 +2137,18 @@ func rgMatchTier(query, title, artistCredit string) int {
// rerankArtists sorts artists by blended score and updates their
// Score field to the new value (0100 scale).
//
//nolint:unused // referenced by deferred MB search rework.
func rerankArtists(artists []MBArtist, pop map[string]int, libraryMBIDs map[string]bool) {
rerankArtistsPersonalized(artists, pop, libraryMBIDs, nil)
}
func rerankArtistsPersonalized(artists []MBArtist, pop map[string]int, inLib map[string]bool, simScores map[string]int) {
func rerankArtistsPersonalized(
artists []MBArtist,
pop map[string]int,
inLib map[string]bool,
simScores map[string]int,
) {
if len(artists) == 0 {
return
}
@@ -2078,13 +2157,29 @@ func rerankArtistsPersonalized(artists []MBArtist, pop map[string]int, inLib map
maxSim := maxSimScoreVal(simScores)
sort.SliceStable(artists, func(i, j int) bool {
si := blendedScoreFull(float64(artists[i].Score)/100.0, pop[artists[i].MBID], maxPop, personalScore(artists[i].MBID, inLib, simScores, maxSim))
sj := blendedScoreFull(float64(artists[j].Score)/100.0, pop[artists[j].MBID], maxPop, personalScore(artists[j].MBID, inLib, simScores, maxSim))
si := blendedScoreFull(
float64(artists[i].Score)/100.0,
pop[artists[i].MBID],
maxPop,
personalScore(artists[i].MBID, inLib, simScores, maxSim),
)
sj := blendedScoreFull(
float64(artists[j].Score)/100.0,
pop[artists[j].MBID],
maxPop,
personalScore(artists[j].MBID, inLib, simScores, maxSim),
)
return si > sj
})
for i := range artists {
s := blendedScoreFull(float64(artists[i].Score)/100.0, pop[artists[i].MBID], maxPop, personalScore(artists[i].MBID, inLib, simScores, maxSim))
s := blendedScoreFull(
float64(artists[i].Score)/100.0,
pop[artists[i].MBID],
maxPop,
personalScore(artists[i].MBID, inLib, simScores, maxSim),
)
artists[i].Score = int(s * 100)
}
}
@@ -2095,7 +2190,12 @@ func rerankRecordings(recordings []MBRecording, pop map[string]int) {
rerankRecordingsPersonalized(recordings, pop, nil, nil)
}
func rerankRecordingsPersonalized(recordings []MBRecording, pop map[string]int, inLib map[string]bool, simScores map[string]int) {
func rerankRecordingsPersonalized(
recordings []MBRecording,
pop map[string]int,
inLib map[string]bool,
simScores map[string]int,
) {
if len(recordings) == 0 {
return
}
@@ -2104,13 +2204,29 @@ func rerankRecordingsPersonalized(recordings []MBRecording, pop map[string]int,
maxSim := maxSimScoreVal(simScores)
sort.SliceStable(recordings, func(i, j int) bool {
si := blendedScoreFull(float64(recordings[i].Score)/100.0, pop[recordings[i].MBID], maxPop, personalScore(recordings[i].MBID, inLib, simScores, maxSim))
sj := blendedScoreFull(float64(recordings[j].Score)/100.0, pop[recordings[j].MBID], maxPop, personalScore(recordings[j].MBID, inLib, simScores, maxSim))
si := blendedScoreFull(
float64(recordings[i].Score)/100.0,
pop[recordings[i].MBID],
maxPop,
personalScore(recordings[i].MBID, inLib, simScores, maxSim),
)
sj := blendedScoreFull(
float64(recordings[j].Score)/100.0,
pop[recordings[j].MBID],
maxPop,
personalScore(recordings[j].MBID, inLib, simScores, maxSim),
)
return si > sj
})
for i := range recordings {
s := blendedScoreFull(float64(recordings[i].Score)/100.0, pop[recordings[i].MBID], maxPop, personalScore(recordings[i].MBID, inLib, simScores, maxSim))
s := blendedScoreFull(
float64(recordings[i].Score)/100.0,
pop[recordings[i].MBID],
maxPop,
personalScore(recordings[i].MBID, inLib, simScores, maxSim),
)
recordings[i].Score = int(s * 100)
}
}
@@ -2121,7 +2237,12 @@ func rerankReleaseGroups(rgs []MBReleaseGroup, pop map[string]int) {
rerankReleaseGroupsPersonalized(rgs, pop, nil, nil)
}
func rerankReleaseGroupsPersonalized(rgs []MBReleaseGroup, pop map[string]int, inLib map[string]bool, simScores map[string]int) {
func rerankReleaseGroupsPersonalized(
rgs []MBReleaseGroup,
pop map[string]int,
inLib map[string]bool,
simScores map[string]int,
) {
if len(rgs) == 0 {
return
}
@@ -2130,13 +2251,29 @@ func rerankReleaseGroupsPersonalized(rgs []MBReleaseGroup, pop map[string]int, i
maxSim := maxSimScoreVal(simScores)
sort.SliceStable(rgs, func(i, j int) bool {
si := blendedScoreFull(float64(rgs[i].Score)/100.0, pop[rgs[i].MBID], maxPop, personalScore(rgs[i].MBID, inLib, simScores, maxSim))
sj := blendedScoreFull(float64(rgs[j].Score)/100.0, pop[rgs[j].MBID], maxPop, personalScore(rgs[j].MBID, inLib, simScores, maxSim))
si := blendedScoreFull(
float64(rgs[i].Score)/100.0,
pop[rgs[i].MBID],
maxPop,
personalScore(rgs[i].MBID, inLib, simScores, maxSim),
)
sj := blendedScoreFull(
float64(rgs[j].Score)/100.0,
pop[rgs[j].MBID],
maxPop,
personalScore(rgs[j].MBID, inLib, simScores, maxSim),
)
return si > sj
})
for i := range rgs {
s := blendedScoreFull(float64(rgs[i].Score)/100.0, pop[rgs[i].MBID], maxPop, personalScore(rgs[i].MBID, inLib, simScores, maxSim))
s := blendedScoreFull(
float64(rgs[i].Score)/100.0,
pop[rgs[i].MBID],
maxPop,
personalScore(rgs[i].MBID, inLib, simScores, maxSim),
)
rgs[i].Score = int(s * 100)
}
}
@@ -2156,7 +2293,12 @@ func maxSimScoreVal(scores map[string]int) int {
// personalScore returns the personalization signal (0.01.0) for an MBID.
// Uses similarity scores from similar_artist_map, scaled by the max score
// in the batch so the most similar artist gets the full personalSimilar weight.
func personalScore(mbid string, inLib map[string]bool, simScores map[string]int, maxSimScore int) float64 {
func personalScore(
mbid string,
inLib map[string]bool,
simScores map[string]int,
maxSimScore int,
) float64 {
if inLib[mbid] {
return personalInLibrary
}
@@ -2168,7 +2310,6 @@ func personalScore(mbid string, inLib map[string]bool, simScores map[string]int,
return 0.0
}
// ---------------------------------------------------------------------------
// Top Results — intent-scored cards
// ---------------------------------------------------------------------------
@@ -2268,6 +2409,7 @@ func (e *Service) resolveTopResults(query string, result *MBSearchResult) []TopR
// Stage 1: gather candidates.
clicks := e.getSearchClicks(q)
exactMatches := e.index.ExactMatches(q, topResultsExactCap)
candidates := e.gatherTopCandidates(q, result, exactMatches, clicks)
if len(candidates) == 0 {
return nil
@@ -2282,6 +2424,7 @@ func (e *Service) resolveTopResults(query string, result *MBSearchResult) []TopR
// matches (query contains both the title and the artist of a
// recording or album) are treated the same way.
var exactCandidates []topCandidate
for _, c := range candidates {
isExact := isExactNameMatch(q, c.topResult.Name) ||
isExactNameMatch(q, c.topResult.ArtistCredit) ||
@@ -2457,7 +2600,7 @@ func (e *Service) gatherTopCandidates(
limit = len(result.Artists)
}
for i := 0; i < limit; i++ {
for i := range limit {
a := result.Artists[i]
quality := e.scoreArtistCandidate(q, &a, clicks)
@@ -2512,10 +2655,11 @@ func (e *Service) gatherTopCandidates(
limit = len(result.ReleaseGroups)
}
for i := 0; i < limit; i++ {
for i := range limit {
rg := result.ReleaseGroups[i]
quality := e.scoreReleaseGroupCandidate(q, &rg, clicks)
year := ""
if len(rg.FirstReleaseDate) >= 4 { //nolint:mnd
year = rg.FirstReleaseDate[:4]
@@ -2586,7 +2730,7 @@ func (e *Service) gatherTopCandidates(
limit = len(result.Recordings)
}
for i := 0; i < limit; i++ {
for i := range limit {
r := result.Recordings[i]
quality := e.scoreRecordingCandidate(q, &r, clicks)
@@ -2857,11 +3001,12 @@ func (e *Service) scoreExactMatch(
score := 0.0
if title == q {
switch {
case title == q:
score += fwExactTitle
} else if credit == q {
case credit == q:
score += fwExactArtist
} else {
default:
// Shouldn't happen — ExactMatches only returns rows whose
// title or artist matches. Defensive fallback.
score += fwContainsWord
@@ -2918,8 +3063,8 @@ func (e *Service) computeIntentPrior(
switch {
case wordCount == 1:
weights.artist *= 2.0 //nolint:mnd
weights.album *= 0.7 //nolint:mnd
weights.artist *= 2.0 //nolint:mnd
weights.album *= 0.7 //nolint:mnd
weights.recording *= 0.7 //nolint:mnd
case wordCount >= 4: //nolint:mnd
weights.artist *= 0.5 //nolint:mnd
@@ -2958,6 +3103,7 @@ func (e *Service) computeIntentPrior(
// index-sourced exact matches.
for _, c := range exactCandidates {
var listeners int
switch c.category {
case "artist":
listeners = artistListenerByMBID(result.Artists, c.topResult.MBID)
@@ -2982,6 +3128,7 @@ func (e *Service) computeIntentPrior(
// Signal: many recordings in the result list with the same
// title as the query → cover-wave pattern → strong recording.
titleMatches := 0
for _, r := range result.Recordings {
if isExactNameMatch(q, r.Title) {
titleMatches++
@@ -3000,17 +3147,17 @@ func (e *Service) computeIntentPrior(
// have higher listen counts than albums (each play increments
// the recording, not the album), so we use *listener* count
// rather than *listen* count to dampen that bias.
artistListeners := sumTopListeners(artistListenerCounts(result.Artists), 5) //nolint:mnd
artistListeners := sumTopListeners(artistListenerCounts(result.Artists), 5) //nolint:mnd
albumListeners := sumTopListeners(rgListenerCounts(result.ReleaseGroups), 5) //nolint:mnd
recListeners := sumTopListeners(recListenerCounts(result.Recordings), 5) //nolint:mnd
recListeners := sumTopListeners(recListenerCounts(result.Recordings), 5) //nolint:mnd
totalListeners := artistListeners + albumListeners + recListeners
if totalListeners > 0 {
// Apply as a 0.5x nudge so it doesn't override stronger
// signals. We'd rather trust shape and exact matches
// than raw listener distributions.
weights.artist *= 1.0 + 0.5*float64(artistListeners)/float64(totalListeners) //nolint:mnd
weights.album *= 1.0 + 0.5*float64(albumListeners)/float64(totalListeners) //nolint:mnd
weights.artist *= 1.0 + 0.5*float64(artistListeners)/float64(totalListeners) //nolint:mnd
weights.album *= 1.0 + 0.5*float64(albumListeners)/float64(totalListeners) //nolint:mnd
weights.recording *= 1.0 + 0.5*float64(recListeners)/float64(totalListeners) //nolint:mnd
}
@@ -3165,12 +3312,13 @@ func sumTopListeners(xs []int, n int) int {
}
sum := 0
for i := 0; i < n; i++ {
for i := range n {
sum += sorted[i]
}
return sum
}
// containsWord checks if text contains word as a whole word bounded
// by spaces, hyphens, or string boundaries.
func containsWord(text, word string) bool {
@@ -3260,10 +3408,12 @@ func normalizeForMatch(s string) string {
r >= '0' && r <= '9',
r >= 0x80: // keep non-ASCII as-is
b.WriteRune(r)
prevSpace = false
case r == ' ' || r == '\t':
if !prevSpace && b.Len() > 0 {
b.WriteByte(' ')
prevSpace = true
}
default:
@@ -3301,9 +3451,11 @@ func (e *Service) getSearchClicks(query string) map[string]searchClick {
result := make(map[string]searchClick)
for rows.Next() {
var mbid string
var count int
var lastClicked time.Time
var (
mbid string
count int
lastClicked time.Time
)
if err := rows.Scan(&mbid, &count, &lastClicked); err == nil {
result[mbid] = searchClick{count: count, lastClicked: lastClicked}
@@ -3334,13 +3486,19 @@ func (e *Service) RecordSearchClick(query, mbid, entityType string) {
// blendedScore computes relevanceWeight*relevance + popularityWeight*logPop.
// relevance is 01. listenCount is raw; maxListenCount is the
// maximum in the result set (for normalization).
//
//nolint:unused // referenced by deferred MB search rework.
func blendedScore(relevance float64, listenCount, maxListenCount int) float64 {
return blendedScoreFull(relevance, listenCount, maxListenCount, 0.0)
}
// blendedScoreFull computes the weighted blend of relevance, popularity,
// and personalization. personalization is 0.01.0.
func blendedScoreFull(relevance float64, listenCount, maxListenCount int, personalization float64) float64 {
func blendedScoreFull(
relevance float64,
listenCount, maxListenCount int,
personalization float64,
) float64 {
effectiveMax := maxListenCount
if effectiveMax < 100_000 { //nolint:mnd
effectiveMax = 100_000
@@ -3356,6 +3514,8 @@ func blendedScoreFull(relevance float64, listenCount, maxListenCount int, person
// and at most mbSearchMaxLimit. Aims for ~15% of total matches so
// the ranking pipeline has enough candidates to surface popular
// results that MB's text relevance alone would bury.
//
//nolint:unused // referenced by deferred MB search rework.
func dynamicSearchLimit(totalMatches int) int {
if totalMatches <= mbSearchLimit {
return mbSearchLimit
+1
View File
@@ -131,6 +131,7 @@ func (idx *LibraryMBIDIndex) AllArtistMBIDs() map[string]string {
return result
}
//nolint:unused // utility kept for future per-MBID existence checks.
func (idx *LibraryMBIDIndex) exists(table, mbid string) bool {
//nolint:gosec // table name is hardcoded from internal callers only
rows, err := idx.db.QueryContext(
+8 -6
View File
@@ -196,9 +196,11 @@ func (c *ListenBrainzClient) SimilarArtists(
if a.Score > b.Score {
return -1
}
if a.Score < b.Score {
return 1
}
return 0
})
@@ -320,12 +322,12 @@ func (c *ListenBrainzClient) ReleaseGroupPopularity(
// /1/metadata/artist/ endpoint. Missing fields: aliases,
// disambiguation, sort_name (those come from MB per-artist).
type ArtistMetadata struct {
MBID string
Name string
Type string // "Group", "Person", etc
Country string // from "area" field
BeginYear int
EndYear int
MBID string
Name string
Type string // "Group", "Person", etc
Country string // from "area" field
BeginYear int
EndYear int
WikidataQID string // extracted from rels
}
+50 -6
View File
@@ -39,7 +39,11 @@ type MusicBrainzClient struct {
// responses in the given Cache. The provided rate limiter is shared
// with all other MB consumers (e.g. artist image resolution) to
// prevent concurrent bursts from triggering 429s.
func NewMusicBrainzClient(cache *Cache, limiter *RateLimiter, logger *slog.Logger) *MusicBrainzClient {
func NewMusicBrainzClient(
cache *Cache,
limiter *RateLimiter,
logger *slog.Logger,
) *MusicBrainzClient {
mb := musicbrainzws2.NewClient(musicbrainzws2.AppInfo{
Name: "YellowJacket",
Version: "dev",
@@ -317,6 +321,45 @@ func (c *MusicBrainzClient) BrowseReleaseGroups(
return out, nil
}
// LookupRelease fetches a single release by MBID (with media +
// recordings). Used by the autotag paste-URL escape hatch.
// Cached for 7 days.
func (c *MusicBrainzClient) LookupRelease(
ctx context.Context, mbid string,
) (*MBRelease, error) {
cacheKey := "mb:lookup:release:" + mbid
if data, ok := c.cache.Get(cacheKey); ok {
var out MBRelease
if err := json.Unmarshal(data, &out); err == nil {
return &out, nil
}
}
if err := c.limiter.Wait(ctx); err != nil {
return nil, err
}
c.logger.Info("musicbrainz lookup release", "mbid", mbid)
r, err := c.mb.LookupRelease(
ctx,
mbtypes.MBID(mbid),
musicbrainzws2.IncludesFilter{
Includes: []string{"recordings", "media", "artist-credits", "release-groups"},
},
)
if err != nil {
return nil, err
}
out := convertRelease(r)
c.cacheJSON(cacheKey, out, cacheTTLEntity, mbid, "release")
return &out, nil
}
// BrowseReleases fetches the releases for a given release group
// MBID, including media/track information. Cached for 7 days.
func (c *MusicBrainzClient) BrowseReleases(
@@ -482,11 +525,12 @@ func convertReleaseGroups(rgs []musicbrainzws2.ReleaseGroup) []MBReleaseGroup {
func convertRelease(r musicbrainzws2.Release) MBRelease {
rel := MBRelease{
MBID: string(r.ID),
Title: r.Title,
Date: r.Date.String(),
Country: string(r.CountryCode),
Status: r.Status,
MBID: string(r.ID),
Title: r.Title,
Date: r.Date.String(),
Country: string(r.CountryCode),
Status: r.Status,
ArtistCredit: r.ArtistCredit.String(),
}
for _, m := range r.Media {
+53 -27
View File
@@ -12,10 +12,10 @@ import (
"sync/atomic"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/database"
"yellowjacket/backend/events"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// Index build parameters.
@@ -88,12 +88,12 @@ const (
// SearchIndexResult is a single hit from the local popularity index.
type SearchIndexResult struct {
EntityType string `json:"entityType"`
MBID string `json:"mbid"`
Title string `json:"title"`
ArtistName string `json:"artistName"`
ArtistMBID string `json:"artistMbid"`
Aliases string `json:"aliases,omitempty"`
EntityType string `json:"entityType"`
MBID string `json:"mbid"`
Title string `json:"title"`
ArtistName string `json:"artistName"`
ArtistMBID string `json:"artistMbid"`
Aliases string `json:"aliases,omitempty"`
// Popularity signals.
Popularity int `json:"popularity"`
@@ -158,10 +158,10 @@ type lbSitewideArtist struct {
// - Tier 4: similar artists to library artists (background, ~24min)
// - Tier 5: organic growth from user browsing (ongoing, free)
type SearchIndex struct {
db *database.DB
lb *ListenBrainzClient
artistImg *ArtistImageProvider
logger *slog.Logger
db *database.DB
lb *ListenBrainzClient
artistImg *ArtistImageProvider
logger *slog.Logger
runtimeCtx context.Context // Wails runtime context for event emission
cancel context.CancelFunc
@@ -441,8 +441,10 @@ func (si *SearchIndex) refreshStatusCounts() {
defer func() { _ = rows.Close() }()
for rows.Next() {
var et string
var count int
var (
et string
count int
)
if err := rows.Scan(&et, &count); err == nil {
switch et {
@@ -509,6 +511,8 @@ func (si *SearchIndex) setTierStatus(name, state string, total, completed int) {
}
// setTierError marks a tier as errored.
//
//nolint:unused // kept for future per-tier failure surfacing.
func (si *SearchIndex) setTierError(name, errMsg string) {
si.mu.Lock()
@@ -599,7 +603,10 @@ func (si *SearchIndex) GetPopularityBatch(mbids []string) *PopularityBatchResult
}
query := "SELECT mbid, popularity, listener_count, in_library FROM explore_index WHERE mbid IN (" +
strings.Join(placeholders, ",") + ")"
strings.Join(
placeholders,
",",
) + ")"
rows, err := si.db.QueryContext(query, args...)
if err != nil {
@@ -616,10 +623,12 @@ func (si *SearchIndex) GetPopularityBatch(mbids []string) *PopularityBatchResult
}
for rows.Next() {
var mbid string
var pop int
var listeners int
var inLib int
var (
mbid string
pop int
listeners int
inLib int
)
if err := rows.Scan(&mbid, &pop, &listeners, &inLib); err == nil {
existing, ok := result.Popularity[mbid]
@@ -707,7 +716,9 @@ func (si *SearchIndex) LookupArtistByMBID(mbid string) *SearchIndexResult {
// rows whose caa_release_mbid matches. Used to find parent release
// groups for tracks so we can fetch cover art via the existing
// release-group endpoint instead of the per-release endpoint.
func (si *SearchIndex) ReleaseGroupMBIDsForCAAReleaseMBIDs(caaReleaseMBIDs []string) map[string]string {
func (si *SearchIndex) ReleaseGroupMBIDsForCAAReleaseMBIDs(
caaReleaseMBIDs []string,
) map[string]string {
if len(caaReleaseMBIDs) == 0 {
return nil
}
@@ -925,8 +936,6 @@ func (si *SearchIndex) AddFromCache(artistName, artistMBID string, rgs []MBRelea
)
}
// Search queries the local FTS5 index and returns matches sorted
// by popularity descending.
// ExactMatches returns index rows whose normalized title (or artist
// name) exactly equals the given query. Used by the top-results
// intent pipeline as a dedicated retrieval source — exact matches
@@ -1017,6 +1026,7 @@ func (si *SearchIndex) ExactMatches(query string, perCategory int) []SearchIndex
}
var out []SearchIndexResult
out = append(out, buckets["artist"]...)
out = append(out, buckets["release_group"]...)
out = append(out, buckets["recording"]...)
@@ -1024,7 +1034,11 @@ func (si *SearchIndex) ExactMatches(query string, perCategory int) []SearchIndex
return out
}
func (si *SearchIndex) Search(query string, limit int) []SearchIndexResult { if !si.IsReady() {
// Search queries the local FTS5 index and returns matches ordered
// by relevance (popularity-blended). Returns nil when the index
// hasn't finished its initial build.
func (si *SearchIndex) Search(query string, limit int) []SearchIndexResult {
if !si.IsReady() {
return nil
}
@@ -1269,7 +1283,12 @@ func (si *SearchIndex) build(ctx context.Context) {
}
si.setMeta("tier2_built", time.Now().UTC().Format(time.RFC3339))
si.setTierStatus("Sitewide Discographies", "complete", len(newSitewide), len(newSitewide))
si.setTierStatus(
"Sitewide Discographies",
"complete",
len(newSitewide),
len(newSitewide),
)
si.refreshStatusCounts()
si.logger.Info("search index: Tier 2 complete (sitewide discographies)")
}
@@ -1324,6 +1343,7 @@ func (si *SearchIndex) build(ctx context.Context) {
} else {
si.setTierStatus("Popularity Backfill", "running", 0, 0)
si.buildTier5Popularity(ctx, indexLB)
if ctx.Err() != nil {
return
}
@@ -1984,6 +2004,7 @@ func (si *SearchIndex) prefetchArtistMetadata(
}
batch := mbids[i:end]
batchWG.Add(1)
go func(chunk []string) {
@@ -2931,7 +2952,10 @@ func (si *SearchIndex) GetSimilarityScores(mbids []string) map[string]int {
}
query := "SELECT similar_artist_mbid, MAX(score) FROM similar_artist_map WHERE similar_artist_mbid IN (" +
strings.Join(placeholders, ",") + ") GROUP BY similar_artist_mbid"
strings.Join(
placeholders,
",",
) + ") GROUP BY similar_artist_mbid"
rows, err := si.db.QueryContext(query, args...)
if err != nil {
@@ -2943,8 +2967,10 @@ func (si *SearchIndex) GetSimilarityScores(mbids []string) map[string]int {
result := make(map[string]int, len(mbids))
for rows.Next() {
var mbid string
var score int
var (
mbid string
score int
)
if err := rows.Scan(&mbid, &score); err == nil {
result[mbid] = score
+13 -12
View File
@@ -20,7 +20,7 @@ type MBSearchResult struct {
// categorized search lists. Computed by intent scoring after all
// reranking is complete.
type TopResult struct {
EntityType string `json:"entityType"` // "artist", "release_group", "recording"
EntityType string `json:"entityType"` // "artist", "release_group", "recording"
MBID string `json:"mbid"`
Name string `json:"name"`
ArtistCredit string `json:"artistCredit,omitempty"` // for tracks/albums
@@ -51,7 +51,7 @@ type MBArtist struct {
HasPopularity bool `json:"-"` // true if LB/index had listen data for this artist
Popularity int `json:"popularity"` // raw LB listen count (0 if unknown)
ListenerCount int `json:"listenerCount"`
InLibrary bool `json:"inLibrary"` // true if the user owns music by this artist
InLibrary bool `json:"inLibrary"` // true if the user owns music by this artist
LocalID int64 `json:"localId,omitempty"` // local artist row ID for navigation
}
@@ -64,21 +64,22 @@ type MBReleaseGroup struct {
SecondaryTypes []string `json:"secondaryTypes,omitempty"`
FirstReleaseDate string `json:"firstReleaseDate"`
ArtistCredit string `json:"artistCredit"`
Score int `json:"-"` // MB search relevance, used for reranking
Popularity int `json:"popularity"` // raw LB listen count (0 if unknown)
Score int `json:"-"` // MB search relevance, used for reranking
Popularity int `json:"popularity"` // raw LB listen count (0 if unknown)
ListenerCount int `json:"listenerCount"`
InLibrary bool `json:"inLibrary"` // true if the user owns this album
InLibrary bool `json:"inLibrary"` // true if the user owns this album
LocalID int64 `json:"localId,omitempty"` // local release_group row ID
}
// MBRelease is a Wails-friendly projection of a MusicBrainz release.
type MBRelease struct {
MBID string `json:"mbid"`
Title string `json:"title"`
Date string `json:"date"`
Country string `json:"country"`
Status string `json:"status"`
Tracks []MBTrack `json:"tracks,omitempty"`
MBID string `json:"mbid"`
Title string `json:"title"`
Date string `json:"date"`
Country string `json:"country"`
Status string `json:"status"`
ArtistCredit string `json:"artistCredit,omitempty"`
Tracks []MBTrack `json:"tracks,omitempty"`
}
// MBRecording is a Wails-friendly projection of a MusicBrainz
@@ -91,7 +92,7 @@ type MBRecording struct {
Score int `json:"score"`
Popularity int `json:"popularity"` // raw LB listen count (0 if unknown)
ListenerCount int `json:"listenerCount"`
InLibrary bool `json:"inLibrary"` // true if the user owns this recording
InLibrary bool `json:"inLibrary"` // true if the user owns this recording
LocalID int64 `json:"localId,omitempty"` // local recording row ID
}
+114 -2
View File
@@ -19,6 +19,7 @@ import (
"github.com/wailsapp/wails/v2/pkg/runtime"
"golang.org/x/sync/errgroup"
"yellowjacket/backend/autotag"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/events"
@@ -1001,8 +1002,19 @@ func (l *Library) saveAudioFile(
basename := filepath.Base(result.absolutePath)
af, err := q.CreateAudioFile(
l.ctx, sqlcgen.CreateAudioFileParams{
groupKey := autotag.GroupKey(
result.libraryID,
result.absolutePath,
tags.DiscNumber,
)
tagStatus := "untagged"
if tags.RecordingMBID != "" {
tagStatus = "user_confirmed"
}
af, err := q.CreateAudioFileWithGroupKey(
l.ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
FilePath: result.absolutePath,
LengthMilliseconds: result.lengthMillis,
FileTypeID: int64(
@@ -1019,6 +1031,8 @@ func (l *Library) saveAudioFile(
FileSize: props.FileSize,
Basename: basename,
LibraryID: result.libraryID,
GroupKey: groupKey,
TagStatus: tagStatus,
})
if err != nil {
return fmt.Errorf(
@@ -1026,6 +1040,24 @@ func (l *Library) saveAudioFile(
)
}
if err := q.UpsertTaggingItemOnTrackAdd(
l.ctx, sqlcgen.UpsertTaggingItemOnTrackAddParams{
GroupKey: groupKey,
LibraryID: result.libraryID,
AlbumName: tags.Album,
AlbumArtist: resolveAlbumArtistName(tags),
DiscNumber: int64(tags.DiscNumber),
},
); err != nil {
l.logger.Warn(
"could not upsert tagging_items row",
"path", result.absolutePath,
"err", err,
)
metrics.addWarning(result.absolutePath, "commit", err)
}
// Index in FTS5 search_index.
title := l.getRecordingName(tags, result.absolutePath)
@@ -1113,6 +1145,16 @@ func (l *Library) updateAudioFileMetadata(
tags = &metadata.TrackMetadata{}
}
if err := l.maybeRebindTaggingGroup(q, result, tags); err != nil {
l.logger.Warn(
"could not rebind tagging group after metadata update",
"path", result.absolutePath,
"err", err,
)
metrics.addWarning(result.absolutePath, "commit", err)
}
title := l.getRecordingName(tags, result.absolutePath)
artistName := tags.Artist
@@ -1299,6 +1341,76 @@ func (l *Library) updateMBIDs(
}
}
// resolveAlbumArtistName returns the album-artist tag for tagging-
// group bookkeeping, falling back to the track artist when the
// album-artist field is empty.
func resolveAlbumArtistName(tags *metadata.TrackMetadata) string {
if tags.AlbumArtist != "" {
return tags.AlbumArtist
}
return tags.Artist
}
// maybeRebindTaggingGroup recomputes the group key from the freshly
// extracted metadata and, if it differs from the row's current
// group_key, migrates the track: decrement the old group's count
// (dropping it if emptied), upsert the new group, and write the new
// key onto the audio_files row. A no-op when the key is unchanged.
func (l *Library) maybeRebindTaggingGroup(
q *sqlcgen.Queries,
result importResult,
tags *metadata.TrackMetadata,
) error {
newKey := autotag.GroupKey(
result.libraryID,
result.absolutePath,
tags.DiscNumber,
)
oldKey, err := q.GetAudioFileGroupKey(l.ctx, result.existingFileID)
if err != nil {
return fmt.Errorf("read existing group_key: %w", err)
}
if oldKey == newKey {
return nil
}
if oldKey != "" {
if err := q.DecrementTaggingItemTrackCount(l.ctx, oldKey); err != nil {
return fmt.Errorf("decrement old group: %w", err)
}
if err := q.DeleteTaggingItemIfEmpty(l.ctx, oldKey); err != nil {
return fmt.Errorf("cleanup old group: %w", err)
}
}
if err := q.UpsertTaggingItemOnTrackAdd(
l.ctx, sqlcgen.UpsertTaggingItemOnTrackAddParams{
GroupKey: newKey,
LibraryID: result.libraryID,
AlbumName: tags.Album,
AlbumArtist: resolveAlbumArtistName(tags),
DiscNumber: int64(tags.DiscNumber),
},
); err != nil {
return fmt.Errorf("upsert new group: %w", err)
}
if err := q.SetAudioFileGroupKey(
l.ctx, sqlcgen.SetAudioFileGroupKeyParams{
GroupKey: newKey,
ID: result.existingFileID,
},
); err != nil {
return fmt.Errorf("write new group_key: %w", err)
}
return nil
}
// processCoverArt saves cover art to disk and upserts the DB record,
// using the cache to skip work for previously seen images. When
// thumbChan is non-nil, thumbnail generation is dispatched to the
+32 -17
View File
@@ -87,24 +87,24 @@ func mapTrackRow(
}
t := Track{
TrackName: title,
ArtistName: artistName,
TrackLength: strconv.FormatInt(lengthMs, 10),
FilePath: filePath,
TrackNumber: trackNumber.Int64,
DiscNumber: discNumber.Int64,
Album: album,
Genre: splitGenres(genre),
Year: year,
Composer: composer,
FileType: fileType,
SampleRate: sampleRate,
BitDepth: bitDepth,
Channels: channels,
Bitrate: bitrate,
FileSize: fileSize,
TrackName: title,
ArtistName: artistName,
TrackLength: strconv.FormatInt(lengthMs, 10),
FilePath: filePath,
TrackNumber: trackNumber.Int64,
DiscNumber: discNumber.Int64,
Album: album,
Genre: splitGenres(genre),
Year: year,
Composer: composer,
FileType: fileType,
SampleRate: sampleRate,
BitDepth: bitDepth,
Channels: channels,
Bitrate: bitrate,
FileSize: fileSize,
PlayCount: playCount,
LastPlayed: lastPlayedStr,
LastPlayed: lastPlayedStr,
ArtistMBID: artistMBID,
ReleaseGroupMBID: releaseGroupMBID,
RecordingMBID: recordingMBID,
@@ -173,6 +173,12 @@ type Artist struct {
}
// Album represents an album for the cover grid display.
//
// Year is the album's preferred display year — the release-group's
// original-release-date (MusicBrainz first-release-date) when known,
// falling back to the file-tag year. ReleaseYear is the file-tag
// year of the specific release in the library; for a 2010 remaster
// of a 1973 album, Year=1973 and ReleaseYear=2010.
type Album struct {
ID int64
Name string
@@ -183,6 +189,7 @@ type Album struct {
CoverArtMedium string
CoverArtLarge string
Year int64
ReleaseYear int64
}
// GetAllTracks returns an array of track structs of every file in the library.
@@ -362,6 +369,8 @@ func (l *Library) GetAllAlbums() ([]Album, error) {
album.Year = row.Year.Int64
}
album.ReleaseYear = row.ReleaseYear
if row.Mbid.Valid {
album.MBID = row.Mbid.String
}
@@ -516,6 +525,8 @@ func (l *Library) GetAlbumsByArtist(
album.Year = row.Year.Int64
}
album.ReleaseYear = row.ReleaseYear
// Convert filesystem path to URL path for the asset handler.
if row.CoverArtPath != "" {
urls := coverart.ResolveURLs(row.CoverArtPath)
@@ -710,6 +721,8 @@ func (l *Library) GetAllAlbumsByLibrary(
album.Year = row.Year.Int64
}
album.ReleaseYear = row.ReleaseYear
if row.Mbid.Valid {
album.MBID = row.Mbid.String
}
@@ -819,6 +832,8 @@ func (l *Library) GetAlbumsByArtistByLibrary(
album.Year = row.Year.Int64
}
album.ReleaseYear = row.ReleaseYear
if row.CoverArtPath != "" {
urls := coverart.ResolveURLs(row.CoverArtPath)
album.CoverArtPath = urls.Original
+251
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"log/slog"
"sync/atomic"
"testing"
"yellowjacket/backend/database"
@@ -742,3 +743,253 @@ func TestEntityCache_EmptyFields(t *testing.T) {
)
}
}
// ---------------------------------------------------------------------------
// commitBatch + tagging_items bookkeeping (phase 008.3)
// ---------------------------------------------------------------------------
func TestCommitBatch_TaggingItemsBookkeeping(t *testing.T) {
t.Parallel()
lib, db := setupTestLibrary(t)
cache := newEntityCache()
metrics := newScanMetrics()
var added, updated, skipped atomic.Int64
batch := []importResult{
{
absolutePath: "/music/Artist/Album 1/01.mp3",
fileType: metadata.MP3,
lengthMillis: 200000,
tags: &metadata.TrackMetadata{
Title: "A1T1", Artist: "Artist", AlbumArtist: "Artist",
Album: "Album 1", TrackNumber: 1, DiscNumber: 0,
},
libraryID: 0,
},
{
absolutePath: "/music/Artist/Album 1/02.mp3",
fileType: metadata.MP3,
lengthMillis: 210000,
tags: &metadata.TrackMetadata{
Title: "A1T2", Artist: "Artist", AlbumArtist: "Artist",
Album: "Album 1", TrackNumber: 2, DiscNumber: 0,
},
libraryID: 0,
},
{
absolutePath: "/music/Artist/Album 2 [Disc 1]/01.mp3",
fileType: metadata.MP3,
lengthMillis: 220000,
tags: &metadata.TrackMetadata{
Title: "A2D1T1", Artist: "Artist", AlbumArtist: "Artist",
Album: "Album 2", TrackNumber: 1, DiscNumber: 1,
},
libraryID: 0,
},
{
absolutePath: "/music/Artist/Album 2 [Disc 2]/01.mp3",
fileType: metadata.MP3,
lengthMillis: 230000,
tags: &metadata.TrackMetadata{
Title: "A2D2T1", Artist: "Artist", AlbumArtist: "Artist",
Album: "Album 2", TrackNumber: 1, DiscNumber: 2,
},
libraryID: 0,
},
{
absolutePath: "/music/Orphan/singleton.mp3",
fileType: metadata.MP3,
lengthMillis: 100000,
tags: &metadata.TrackMetadata{
Title: "Orphan", Artist: "Solo", AlbumArtist: "Solo",
Album: "", TrackNumber: 0, DiscNumber: 0,
},
libraryID: 0,
},
}
if err := lib.commitBatch(batch, cache, metrics, &added, &updated, &skipped, nil); err != nil {
t.Fatalf("commitBatch: %v", err)
}
if added.Load() != int64(len(batch)) {
for _, w := range metrics.Warnings {
t.Logf("warning: path=%s phase=%s err=%s", w.FilePath, w.Phase, w.Err)
}
t.Fatalf("added = %d, want %d (skipped=%d)", added.Load(), len(batch), skipped.Load())
}
groupCount := queryInt(t, db, "SELECT COUNT(*) FROM tagging_items")
if groupCount != 4 {
t.Errorf("tagging_items count = %d, want 4", groupCount)
}
// Each group should carry the expected track_count.
wantCounts := map[[3]any]int64{
{int64(0), "Album 1", int64(0)}: 2,
{int64(0), "Album 2", int64(1)}: 1,
{int64(0), "Album 2", int64(2)}: 1,
{int64(0), "", int64(0)}: 1,
}
for key, want := range wantCounts {
libID, _ := key[0].(int64)
album, _ := key[1].(string)
disc, _ := key[2].(int64)
rows, err := db.QueryContext(
`SELECT track_count FROM tagging_items
WHERE library_id = ? AND album_name = ? AND disc_number = ?`,
libID, album, disc,
)
if err != nil {
t.Errorf("query track_count for %v: %v", key, err)
continue
}
var got int64
if rows.Next() {
if scanErr := rows.Scan(&got); scanErr != nil {
t.Errorf("scan track_count for %v: %v", key, scanErr)
}
}
_ = rows.Close()
if got != want {
t.Errorf("track_count for %v = %d, want %d", key, got, want)
}
}
}
func TestCommitBatch_AlbumTagChangeKeepsGroup(t *testing.T) {
t.Parallel()
// Folder-based grouping: if a track stays in the same folder
// but its album tag changes (very common — autotag itself
// rewrites album tags), the group_key should NOT change. Test
// guards against the old behavior where any album-tag drift
// would split the album into multiple groups.
lib, db := setupTestLibrary(t)
cache := newEntityCache()
metrics := newScanMetrics()
var added, updated, skipped atomic.Int64
initial := []importResult{
{
absolutePath: "/music/Artist/Album Folder/01.mp3",
fileType: metadata.MP3,
lengthMillis: 200000,
tags: &metadata.TrackMetadata{
Title: "Track", Artist: "Artist", AlbumArtist: "Artist",
Album: "Old Album",
},
libraryID: 0,
},
}
if err := lib.commitBatch(
initial,
cache,
metrics,
&added,
&updated,
&skipped,
nil,
); err != nil {
t.Fatalf("initial commitBatch: %v", err)
}
var (
fileID int64
originalGroup string
)
rows, err := db.QueryContext(
`SELECT id, group_key FROM audio_files WHERE file_path = ?`,
"/music/Artist/Album Folder/01.mp3",
)
if err != nil {
t.Fatalf("lookup file: %v", err)
}
if rows.Next() {
if scanErr := rows.Scan(&fileID, &originalGroup); scanErr != nil {
t.Fatalf("scan file: %v", scanErr)
}
}
_ = rows.Close()
update := []importResult{
{
absolutePath: "/music/Artist/Album Folder/01.mp3",
fileType: metadata.MP3,
lengthMillis: 200000,
existingFileID: fileID,
needsUpdate: true,
tags: &metadata.TrackMetadata{
Title: "Track", Artist: "Artist", AlbumArtist: "Artist",
Album: "Albums Canonical Name (Remastered 2024)",
},
libraryID: 0,
},
}
if err := lib.commitBatch(update, cache, metrics, &added, &updated, &skipped, nil); err != nil {
t.Fatalf("update commitBatch: %v", err)
}
groupCount := queryInt(t, db, `SELECT COUNT(*) FROM tagging_items`)
if groupCount != 1 {
t.Errorf("expected exactly 1 tagging_items row, got %d", groupCount)
}
rows2, err := db.QueryContext(
`SELECT group_key FROM audio_files WHERE id = ?`, fileID,
)
if err != nil {
t.Fatalf("lookup post-update: %v", err)
}
var afterGroup string
if rows2.Next() {
if scanErr := rows2.Scan(&afterGroup); scanErr != nil {
t.Fatalf("scan post-update: %v", scanErr)
}
}
_ = rows2.Close()
if afterGroup != originalGroup {
t.Errorf("group_key changed across album tag edit: %q → %q", originalGroup, afterGroup)
}
}
// queryInt runs a single-column scalar query and returns the first
// int64 result; fails the test on any error.
func queryInt(t *testing.T, db *database.DB, query string, args ...any) int64 {
t.Helper()
rows, err := db.QueryContext(query, args...)
if err != nil {
t.Fatalf("query %q: %v", query, err)
}
defer func() { _ = rows.Close() }()
var got int64
if rows.Next() {
if err := rows.Scan(&got); err != nil {
t.Fatalf("scan %q: %v", query, err)
}
}
return got
}
+8 -4
View File
@@ -6,15 +6,19 @@ import (
"testing"
)
// testMP3Files returns the paths to all .mp3 files in the test_data
// directory. It skips the test if none are found.
// testMP3Files returns the paths to all .mp3 files in the curated
// fixture library (`test_data/music_library_test/`). Scoped
// narrowly so that ad-hoc scramble / autotag fixtures placed
// elsewhere under `test_data/` (e.g. `test_data/mb-tag/`) don't
// get pulled into the assertion and fail on non-curated codecs.
// Skips the test when the directory isn't present.
func testMP3Files(t *testing.T) []string {
t.Helper()
root := filepath.Join("..", "..", "test_data")
root := filepath.Join("..", "..", "test_data", "music_library_test")
if _, err := os.Stat(root); os.IsNotExist(err) {
t.Skip("test_data directory not present, skipping")
t.Skip("test_data/music_library_test not present, skipping")
}
var files []string
+19 -2
View File
@@ -1529,6 +1529,7 @@ func (s *Service) RepopulateFromM3U() {
"could not get playlists dir for repopulation",
"err", err,
)
return
}
@@ -1536,6 +1537,7 @@ func (s *Service) RepopulateFromM3U() {
playlists, err := s.db.Queries.GetAllPlaylists(s.db.Ctx)
if err != nil {
s.logger.Warn("could not get playlists for repopulation", "err", err)
return
}
@@ -1547,18 +1549,25 @@ func (s *Service) RepopulateFromM3U() {
)
if err != nil {
s.logger.Warn("could not query audio files for repopulation", "err", err)
return
}
audioFileByPath := make(map[string]int64)
for afRows.Next() {
var id int64
var fp string
var (
id int64
fp string
)
if err := afRows.Scan(&id, &fp); err != nil {
continue
}
audioFileByPath[fp] = id
}
_ = afRows.Close()
knownPaths := make(map[string]struct{}, len(audioFileByPath))
@@ -1577,11 +1586,14 @@ func (s *Service) RepopulateFromM3U() {
if err != nil {
continue
}
var count int
if countRows.Next() {
_ = countRows.Scan(&count)
}
_ = countRows.Close()
if count > 0 {
continue
}
@@ -1599,6 +1611,7 @@ func (s *Service) RepopulateFromM3U() {
"path", m3uPath,
"err", err,
)
continue
}
@@ -1626,8 +1639,10 @@ func (s *Service) RepopulateFromM3U() {
"position", i,
"err", addErr,
)
continue
}
resolved++
} else {
// Phantom track — preserve what we have from M3U8.
@@ -1644,8 +1659,10 @@ func (s *Service) RepopulateFromM3U() {
"position", i,
"err", addErr,
)
continue
}
phantom++
}
}
+5 -5
View File
@@ -460,11 +460,11 @@ func (q *Queue) RestoreState() {
}
q.tracks = append(q.tracks, Track{
ID: row.ID,
AudioFileID: row.AudioFileID,
FilePath: row.FilePath,
Position: row.Position,
Title: row.Title,
ID: row.ID,
AudioFileID: row.AudioFileID,
FilePath: row.FilePath,
Position: row.Position,
Title: row.Title,
Artist: row.Artist,
Album: row.Album,
CoverArtPath: coverArtURL,