feat: autotag scoring overhaul, dump-based explore index, and lyrics search

Consolidates in-progress work across autotag, explore, and library:

- autotag: beets/Picard-informed scoring engine — ID-first matching, VA
  handling, recommendation tiers, and a merged distance/rank cascade, with
  an eval harness for regression tracking.
- explore: offline MusicBrainz dump import/incremental refresh replaces the
  legacy tier crawl; index-first local search with fuzzy matching and a
  dedicated ranker; disk-free guards for dump downloads.
- library: artist-credit extraction and matching.
- lyrics: owned-library lyric search (FTS) with LRCLIB backfill.

Also: rewrite README to be user-focused, and migrate upstream to
git.ljones.me/yonlu/yellowjacket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 12:14:20 -04:00
co-authored by Claude Opus 4.8
parent d5140395da
commit 65048401e8
117 changed files with 17033 additions and 4767 deletions
+72
View File
@@ -0,0 +1,72 @@
package library
import (
"strings"
"yellowjacket/backend/metadata"
)
// featuringSeparators are the credit join phrases that introduce a
// featured (non-primary) artist. Only true "featuring" markers are
// listed: separators like "&", "x", "with", and "," are deliberately
// excluded because they routinely appear inside real artist names
// (e.g. "Simon & Garfunkel", "Tyler, the Creator").
var featuringSeparators = []string{
" feat. ", " feat ", " featuring ", " ft. ", " ft ",
}
// stripFeaturing returns the credit up to its first "featuring" marker,
// yielding the primary-artist portion of a credit string. "Lana Del
// Rey ft. Sean Lennon" becomes "Lana Del Rey"; a credit with no marker
// is returned unchanged (trimmed). Matching is case-insensitive.
func stripFeaturing(credit string) string {
lower := strings.ToLower(credit)
cut := -1
for _, sep := range featuringSeparators {
if i := strings.Index(lower, sep); i >= 0 && (cut < 0 || i < cut) {
cut = i
}
}
if cut < 0 {
return strings.TrimSpace(credit)
}
return strings.TrimSpace(credit[:cut])
}
// primaryArtist resolves the single canonical artist a track credit
// should map to, plus that artist's MusicBrainz ID. A file tags its
// ARTIST as a full credit string ("Lana Del Rey ft. Sean Lennon") but
// carries only one MUSICBRAINZ_ARTISTID — the primary artist's. Storing
// the whole credit as an artist entity, and stamping the primary MBID on
// it, is what produced duplicate, mis-titled artists (one MBID fanned
// out across many rows); instead we resolve the primary artist's clean
// name here and keep the full credit only as the artist_credit text.
//
// The clean name comes from the album-artist tag when the track resolves
// to the same MBID as the album artist (the common "Album Artist feat.
// Guest" case, where ALBUMARTIST is the authoritative name). Otherwise
// the featured clause is stripped from the credit string.
func primaryArtist(tags *metadata.TrackMetadata) (name, mbid string) {
mbid = tags.ArtistMBID
if mbid == "" {
mbid = tags.AlbumArtistMBID
}
if tags.ArtistMBID != "" &&
tags.ArtistMBID == tags.AlbumArtistMBID &&
tags.AlbumArtist != "" {
name = tags.AlbumArtist
} else {
name = stripFeaturing(tags.Artist)
}
if name == "" {
name = "Unknown Artist"
}
return name, mbid
}
+126
View File
@@ -0,0 +1,126 @@
package library
import (
"testing"
"yellowjacket/backend/metadata"
)
func TestStripFeaturing(t *testing.T) {
t.Parallel()
tests := []struct {
name string
credit string
want string
}{
{"plain", "Lana Del Rey", "Lana Del Rey"},
{"ft dot", "Lana Del Rey ft. Sean Lennon", "Lana Del Rey"},
{"feat dot", "2Pac feat. Nate Dogg", "2Pac"},
{"featuring", "Beyoncé featuring The Weeknd", "Beyoncé"},
{"ft no dot", "Drake ft Travis Scott", "Drake"},
{"case insensitive", "Kanye West FEAT. PARTYNEXTDOOR", "Kanye West"},
{"ampersand kept", "Simon & Garfunkel", "Simon & Garfunkel"},
{"comma kept", "Tyler, the Creator", "Tyler, the Creator"},
{"first marker wins", "A feat. B ft. C", "A"},
{"trims", " Daft Punk feat. Panda Bear ", "Daft Punk"},
{"empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := stripFeaturing(tt.credit); got != tt.want {
t.Errorf("stripFeaturing(%q) = %q, want %q", tt.credit, got, tt.want)
}
})
}
}
func TestPrimaryArtist(t *testing.T) {
t.Parallel()
const lana = "b7539c32-53e7-4908-bda3-81449c367da6"
tests := []struct {
name string
tags metadata.TrackMetadata
wantName string
wantMBID string
}{
{
name: "collab on own album uses clean album artist",
tags: metadata.TrackMetadata{
Artist: "Lana Del Rey ft. Sean Lennon",
AlbumArtist: "Lana Del Rey",
ArtistMBID: lana,
AlbumArtistMBID: lana,
},
wantName: "Lana Del Rey",
wantMBID: lana,
},
{
name: "solo track",
tags: metadata.TrackMetadata{
Artist: "Lana Del Rey",
AlbumArtist: "Lana Del Rey",
ArtistMBID: lana,
AlbumArtistMBID: lana,
},
wantName: "Lana Del Rey",
wantMBID: lana,
},
{
name: "compilation: album artist differs, strip featuring, keep track mbid",
tags: metadata.TrackMetadata{
Artist: "Some Artist feat. Guest",
AlbumArtist: "Various Artists",
ArtistMBID: "aaaa",
AlbumArtistMBID: "va-mbid",
},
wantName: "Some Artist",
wantMBID: "aaaa",
},
{
name: "no album artist, strip featuring",
tags: metadata.TrackMetadata{
Artist: "Some Artist feat. Guest",
ArtistMBID: "aaaa",
},
wantName: "Some Artist",
wantMBID: "aaaa",
},
{
name: "no track mbid falls back to album mbid",
tags: metadata.TrackMetadata{
Artist: "Solo",
AlbumArtist: "Solo",
AlbumArtistMBID: "album-mbid",
},
wantName: "Solo",
wantMBID: "album-mbid",
},
{
name: "empty artist",
tags: metadata.TrackMetadata{},
wantName: "Unknown Artist",
wantMBID: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotName, gotMBID := primaryArtist(&tt.tags)
if gotName != tt.wantName {
t.Errorf("primaryArtist name = %q, want %q", gotName, tt.wantName)
}
if gotMBID != tt.wantMBID {
t.Errorf("primaryArtist mbid = %q, want %q", gotMBID, tt.wantMBID)
}
})
}
}
+9
View File
@@ -37,6 +37,9 @@ type RemovalHooks struct {
StopPlayback func()
// CompactQueue reloads queue state after cascade deletes.
CompactQueue func()
// PostRemove runs after the removal commits, for cross-cutting
// invalidation (e.g. clearing library-sync "ready" markers).
PostRemove func()
}
// SetRemovalHooks provides optional hooks for cross-cutting
@@ -461,6 +464,12 @@ func (l *Library) RemoveLibrary(id int64) (*RemovalSummary, error) {
l.removalHooks.CompactQueue()
}
// 22. Post-commit: invalidate library-sync markers so the gated
// index/lyric re-sync runs on the next launch.
if l.removalHooks.PostRemove != nil {
l.removalHooks.PostRemove()
}
summary := &RemovalSummary{
TracksDeleted: tracksDeleted,
ArtistsRemoved: artistsRemoved,
+17 -14
View File
@@ -1216,14 +1216,19 @@ func (l *Library) processMetadata(
q, cache, metrics, tags, thumbChan,
)
// 2. Get or create artist credit for track artist.
artistName := tags.Artist
if artistName == "" {
artistName = "Unknown Artist"
// 2. Get or create the artist credit for the track. The credit
// text is the full tagged string (e.g. "Lana Del Rey ft. Sean
// Lennon") and is kept only for display; the artist *entity* it
// links to is the primary artist, resolved cleanly by primaryArtist
// so featured-artist credits don't fork into their own bogus artist
// rows (all sharing the primary's single MBID).
creditText := tags.Artist
if creditText == "" {
creditText = "Unknown Artist"
}
artistCredit, err := l.cachedUpsertArtistCredit(
q, cache, artistName,
q, cache, creditText,
)
if err != nil {
return 0, fmt.Errorf(
@@ -1231,7 +1236,9 @@ func (l *Library) processMetadata(
)
}
l.cachedLinkArtist(q, cache, metrics, artistName, artistCredit.ID)
primaryName, primaryMBID := primaryArtist(tags)
l.cachedLinkArtist(q, cache, metrics, primaryName, artistCredit.ID)
// 3. Get or create artist credit for album artist.
albumArtistCreditID := l.resolveAlbumArtistCredit(
@@ -1289,9 +1296,9 @@ func (l *Library) processMetadata(
// 7. Update MusicBrainz IDs (if present in tags).
if releaseGroupID.Valid {
l.updateMBIDs(tx, cache, tags, artistName, releaseGroupID.Int64, recording.ID)
l.updateMBIDs(tx, cache, tags, primaryName, primaryMBID, releaseGroupID.Int64, recording.ID)
} else {
l.updateMBIDs(tx, cache, tags, artistName, 0, recording.ID)
l.updateMBIDs(tx, cache, tags, primaryName, primaryMBID, 0, recording.ID)
}
return recording.ID, nil
@@ -1306,15 +1313,11 @@ func (l *Library) updateMBIDs(
cache *entityCache,
tags *metadata.TrackMetadata,
artistName string,
artistMBID string,
releaseGroupID int64,
recordingID int64,
) {
// Artist MBID — prefer album artist, fall back to track artist.
artistMBID := tags.AlbumArtistMBID
if artistMBID == "" {
artistMBID = tags.ArtistMBID
}
// Artist MBID (the primary artist's, resolved by primaryArtist).
if artistMBID != "" {
if artist, ok := cache.artists[artistName]; ok {
_, _ = tx.ExecContext(l.ctx,
+21 -16
View File
@@ -183,6 +183,7 @@ type Album struct {
ID int64
Name string
ArtistName string
ArtistMBID string
MBID string
CoverArtPath string
CoverArtSmall string
@@ -194,7 +195,7 @@ type Album struct {
// GetAllTracks returns an array of track structs of every file in the library.
func (l *Library) GetAllTracks() ([]Track, error) {
rows, err := l.db.Queries.GetAllTracksWithFullMetadata(
rows, err := l.db.ReadQueries.GetAllTracksWithFullMetadata(
l.ctx,
)
if err != nil {
@@ -303,7 +304,7 @@ func (l *Library) SearchTracks(
// GetAlbumTracks returns all tracks for a given album (release group), ordered by disc and track number.
func (l *Library) GetAlbumTracks(albumID int64) ([]Track, error) {
rows, err := l.db.Queries.GetAudioFilesByReleaseGroup(l.ctx, albumID)
rows, err := l.db.ReadQueries.GetAudioFilesByReleaseGroup(l.ctx, albumID)
if err != nil {
l.logger.Error("could not retrieve album tracks", "albumID", albumID, "error", err)
@@ -347,7 +348,7 @@ func (l *Library) GetAlbumTracks(albumID int64) ([]Track, error) {
// GetAllAlbums returns all albums with cover art and artist info for the cover grid.
func (l *Library) GetAllAlbums() ([]Album, error) {
rows, err := l.db.Queries.GetAllAlbumsWithDetails(l.ctx)
rows, err := l.db.ReadQueries.GetAllAlbumsWithDetails(l.ctx)
if err != nil {
l.logger.Error("could not retrieve albums", "error", err)
@@ -363,6 +364,7 @@ func (l *Library) GetAllAlbums() ([]Album, error) {
ID: row.ID,
Name: row.Name,
ArtistName: row.ArtistName,
ArtistMBID: row.ArtistMbid,
}
if row.Year.Valid {
@@ -392,7 +394,7 @@ func (l *Library) GetAllAlbums() ([]Album, error) {
// GetAllArtists returns artists that are credited as album artists, ordered by name.
func (l *Library) GetAllArtists() ([]Artist, error) {
rows, err := l.db.Queries.GetAlbumArtists(l.ctx)
rows, err := l.db.ReadQueries.GetAlbumArtists(l.ctx)
if err != nil {
l.logger.Error(
"could not retrieve artists",
@@ -489,7 +491,7 @@ func (l *Library) resolveArtistImages(artists []Artist) {
func (l *Library) GetAlbumsByArtist(
artistID int64,
) ([]Album, error) {
rows, err := l.db.Queries.GetAlbumsByArtist(
rows, err := l.db.ReadQueries.GetAlbumsByArtist(
l.ctx,
artistID,
)
@@ -519,6 +521,7 @@ func (l *Library) GetAlbumsByArtist(
ID: row.ID,
Name: row.Name,
ArtistName: row.ArtistName,
ArtistMBID: row.ArtistMbid,
}
if row.Year.Valid {
@@ -552,7 +555,7 @@ type GenreWithCount struct {
func (l *Library) GetTracksByGenre(
genreName string,
) ([]Track, error) {
rows, err := l.db.Queries.GetTracksByGenre(
rows, err := l.db.ReadQueries.GetTracksByGenre(
l.ctx, genreName,
)
if err != nil {
@@ -600,7 +603,7 @@ func (l *Library) GetTracksByGenre(
func (l *Library) GetAllGenresWithCounts() (
[]GenreWithCount, error,
) {
rows, err := l.db.Queries.GetAllGenresWithCounts(
rows, err := l.db.ReadQueries.GetAllGenresWithCounts(
l.ctx,
)
if err != nil {
@@ -630,7 +633,7 @@ func (l *Library) GetAllGenresWithCounts() (
func (l *Library) GetAllTracksByLibrary(
libraryID int64,
) ([]Track, error) {
rows, err := l.db.Queries.GetAllTracksWithFullMetadataByLibrary(
rows, err := l.db.ReadQueries.GetAllTracksWithFullMetadataByLibrary(
l.ctx, libraryID,
)
if err != nil {
@@ -687,7 +690,7 @@ func (l *Library) GetAllTracksByLibrary(
func (l *Library) GetAllAlbumsByLibrary(
libraryID int64,
) ([]Album, error) {
rows, err := l.db.Queries.GetAllAlbumsWithDetailsByLibrary(
rows, err := l.db.ReadQueries.GetAllAlbumsWithDetailsByLibrary(
l.ctx, libraryID,
)
if err != nil {
@@ -715,6 +718,7 @@ func (l *Library) GetAllAlbumsByLibrary(
ID: row.ID,
Name: row.Name,
ArtistName: row.ArtistName,
ArtistMBID: row.ArtistMbid,
}
if row.Year.Valid {
@@ -746,7 +750,7 @@ func (l *Library) GetAllAlbumsByLibrary(
func (l *Library) GetAllArtistsByLibrary(
libraryID int64,
) ([]Artist, error) {
rows, err := l.db.Queries.GetAlbumArtistsByLibrary(
rows, err := l.db.ReadQueries.GetAlbumArtistsByLibrary(
l.ctx, libraryID,
)
if err != nil {
@@ -792,7 +796,7 @@ func (l *Library) GetAllArtistsByLibrary(
func (l *Library) GetAlbumsByArtistByLibrary(
artistID, libraryID int64,
) ([]Album, error) {
rows, err := l.db.Queries.GetAlbumsByArtistByLibrary(
rows, err := l.db.ReadQueries.GetAlbumsByArtistByLibrary(
l.ctx, sqlcgen.GetAlbumsByArtistByLibraryParams{
ArtistID: artistID,
LibraryID: libraryID,
@@ -826,6 +830,7 @@ func (l *Library) GetAlbumsByArtistByLibrary(
ID: row.ID,
Name: row.Name,
ArtistName: row.ArtistName,
ArtistMBID: row.ArtistMbid,
}
if row.Year.Valid {
@@ -853,7 +858,7 @@ func (l *Library) GetAlbumsByArtistByLibrary(
func (l *Library) GetAllGenresWithCountsByLibrary(
libraryID int64,
) ([]GenreWithCount, error) {
rows, err := l.db.Queries.GetAllGenresWithCountsByLibrary(
rows, err := l.db.ReadQueries.GetAllGenresWithCountsByLibrary(
l.ctx, libraryID,
)
if err != nil {
@@ -885,7 +890,7 @@ func (l *Library) GetAllGenresWithCountsByLibrary(
func (l *Library) GetTracksByGenreByLibrary(
genreName string, libraryID int64,
) ([]Track, error) {
rows, err := l.db.Queries.GetTracksByGenreByLibrary(
rows, err := l.db.ReadQueries.GetTracksByGenreByLibrary(
l.ctx, sqlcgen.GetTracksByGenreByLibraryParams{
Name: genreName,
LibraryID: libraryID,
@@ -939,7 +944,7 @@ func (l *Library) GetTracksByGenreByLibrary(
func (l *Library) GetAlbumTracksByLibrary(
albumID, libraryID int64,
) ([]Track, error) {
rows, err := l.db.Queries.GetAudioFilesByReleaseGroupByLibrary(
rows, err := l.db.ReadQueries.GetAudioFilesByReleaseGroupByLibrary(
l.ctx, sqlcgen.GetAudioFilesByReleaseGroupByLibraryParams{
ReleaseGroupID: albumID,
LibraryID: libraryID,
@@ -1052,7 +1057,7 @@ type Info struct {
// GetAllLibrariesWithTrackCounts returns all libraries with their
// audio file counts. Typically 1-5 libraries so the loop is trivial.
func (l *Library) GetAllLibrariesWithTrackCounts() ([]Info, error) {
libs, err := l.db.Queries.GetAllLibraries(l.ctx)
libs, err := l.db.ReadQueries.GetAllLibraries(l.ctx)
if err != nil {
return nil, fmt.Errorf("could not get libraries: %w", err)
}
@@ -1060,7 +1065,7 @@ func (l *Library) GetAllLibrariesWithTrackCounts() ([]Info, error) {
result := make([]Info, 0, len(libs))
for _, lib := range libs {
count, countErr := l.db.Queries.CountAudioFilesByLibrary(l.ctx, lib.ID)
count, countErr := l.db.ReadQueries.CountAudioFilesByLibrary(l.ctx, lib.ID)
if countErr != nil {
l.logger.Error("could not count tracks for library",
"libraryID", lib.ID, "error", countErr)