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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user