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