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
+17 -6
View File
@@ -85,6 +85,10 @@ type Library struct {
AutotagWarningAcked int64
}
type LyricsIndex struct {
Lyrics string
}
type PlayHistory struct {
ID int64
AudioFileID int64
@@ -100,12 +104,13 @@ type PlayerState struct {
}
type Playlist struct {
ID int64
Name string
IsSmart int64
SmartRules sql.NullString
CreatedAt time.Time
UpdatedAt time.Time
ID int64
Name string
IsSmart int64
SmartRules sql.NullString
SmartSnapshotAt sql.NullTime
CreatedAt time.Time
UpdatedAt time.Time
}
type PlaylistTrack struct {
@@ -184,6 +189,12 @@ type SearchIndex struct {
Album string
}
type TaggingCandidate struct {
GroupKey string
Candidates string
ComputedAt time.Time
}
type TaggingItem struct {
GroupKey string
LibraryID int64
@@ -82,7 +82,7 @@ func (q *Queries) CountPlaylistsByName(ctx context.Context, name string) (int64,
const createPlaylist = `-- name: CreatePlaylist :one
INSERT INTO playlists (name) VALUES (?)
RETURNING id, name, is_smart, smart_rules, created_at, updated_at
RETURNING id, name, is_smart, smart_rules, smart_snapshot_at, created_at, updated_at
`
func (q *Queries) CreatePlaylist(ctx context.Context, name string) (Playlist, error) {
@@ -93,6 +93,7 @@ func (q *Queries) CreatePlaylist(ctx context.Context, name string) (Playlist, er
&i.Name,
&i.IsSmart,
&i.SmartRules,
&i.SmartSnapshotAt,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -205,7 +206,7 @@ func (q *Queries) GetAllPlaylistTracksWithMetadata(ctx context.Context) ([]GetAl
}
const getAllPlaylists = `-- name: GetAllPlaylists :many
SELECT id, name, is_smart, smart_rules, created_at, updated_at FROM playlists ORDER BY updated_at DESC
SELECT id, name, is_smart, smart_rules, smart_snapshot_at, created_at, updated_at FROM playlists ORDER BY updated_at DESC
`
func (q *Queries) GetAllPlaylists(ctx context.Context) ([]Playlist, error) {
@@ -222,6 +223,7 @@ func (q *Queries) GetAllPlaylists(ctx context.Context) ([]Playlist, error) {
&i.Name,
&i.IsSmart,
&i.SmartRules,
&i.SmartSnapshotAt,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -251,7 +253,7 @@ func (q *Queries) GetNextPlaylistTrackPosition(ctx context.Context, playlistID i
}
const getPlaylist = `-- name: GetPlaylist :one
SELECT id, name, is_smart, smart_rules, created_at, updated_at FROM playlists WHERE id = ? LIMIT 1
SELECT id, name, is_smart, smart_rules, smart_snapshot_at, created_at, updated_at FROM playlists WHERE id = ? LIMIT 1
`
func (q *Queries) GetPlaylist(ctx context.Context, id int64) (Playlist, error) {
@@ -262,6 +264,7 @@ func (q *Queries) GetPlaylist(ctx context.Context, id int64) (Playlist, error) {
&i.Name,
&i.IsSmart,
&i.SmartRules,
&i.SmartSnapshotAt,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -109,6 +109,17 @@ SELECT
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
-- primary (first-credited) album artist's MBID, for linking the
-- artist name to its detail page. Empty when the album has no
-- MB-tagged album-artist credit.
CAST(COALESCE((
SELECT a.mbid
FROM artist_credit_artist aca_p
JOIN artists a ON a.id = aca_p.artist_id
WHERE aca_p.credit_id = rg.album_artist_credit_id
ORDER BY aca_p.id
LIMIT 1
), '') AS TEXT) as artist_mbid,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
@@ -131,6 +142,7 @@ type GetAlbumsByArtistRow struct {
Year sql.NullInt64
ReleaseYear int64
ArtistName string
ArtistMbid string
CoverArtPath string
}
@@ -149,6 +161,7 @@ func (q *Queries) GetAlbumsByArtist(ctx context.Context, artistID int64) ([]GetA
&i.Year,
&i.ReleaseYear,
&i.ArtistName,
&i.ArtistMbid,
&i.CoverArtPath,
); err != nil {
return nil, err
@@ -171,6 +184,17 @@ SELECT
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
-- primary (first-credited) album artist's MBID, for linking the
-- artist name to its detail page. Empty when the album has no
-- MB-tagged album-artist credit.
CAST(COALESCE((
SELECT a.mbid
FROM artist_credit_artist aca_p
JOIN artists a ON a.id = aca_p.artist_id
WHERE aca_p.credit_id = rg.album_artist_credit_id
ORDER BY aca_p.id
LIMIT 1
), '') AS TEXT) as artist_mbid,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
@@ -205,6 +229,7 @@ type GetAlbumsByArtistByLibraryRow struct {
Year sql.NullInt64
ReleaseYear int64
ArtistName string
ArtistMbid string
CoverArtPath string
}
@@ -223,6 +248,7 @@ func (q *Queries) GetAlbumsByArtistByLibrary(ctx context.Context, arg GetAlbumsB
&i.Year,
&i.ReleaseYear,
&i.ArtistName,
&i.ArtistMbid,
&i.CoverArtPath,
); err != nil {
return nil, err
@@ -250,6 +276,17 @@ SELECT
COALESCE(rg.year, 0) AS release_year,
rg.mbid,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
-- primary (first-credited) album artist's MBID, for linking the
-- artist name to its detail page. Empty when the album has no
-- MB-tagged album-artist credit.
CAST(COALESCE((
SELECT a.mbid
FROM artist_credit_artist aca_p
JOIN artists a ON a.id = aca_p.artist_id
WHERE aca_p.credit_id = rg.album_artist_credit_id
ORDER BY aca_p.id
LIMIT 1
), '') AS TEXT) as artist_mbid,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
@@ -271,6 +308,7 @@ type GetAllAlbumsWithDetailsRow struct {
ReleaseYear int64
Mbid sql.NullString
ArtistName string
ArtistMbid string
CoverArtPath string
}
@@ -290,6 +328,7 @@ func (q *Queries) GetAllAlbumsWithDetails(ctx context.Context) ([]GetAllAlbumsWi
&i.ReleaseYear,
&i.Mbid,
&i.ArtistName,
&i.ArtistMbid,
&i.CoverArtPath,
); err != nil {
return nil, err
@@ -317,6 +356,17 @@ SELECT
COALESCE(rg.year, 0) AS release_year,
rg.mbid,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
-- primary (first-credited) album artist's MBID, for linking the
-- artist name to its detail page. Empty when the album has no
-- MB-tagged album-artist credit.
CAST(COALESCE((
SELECT a.mbid
FROM artist_credit_artist aca_p
JOIN artists a ON a.id = aca_p.artist_id
WHERE aca_p.credit_id = rg.album_artist_credit_id
ORDER BY aca_p.id
LIMIT 1
), '') AS TEXT) as artist_mbid,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
@@ -345,6 +395,7 @@ type GetAllAlbumsWithDetailsByLibraryRow struct {
ReleaseYear int64
Mbid sql.NullString
ArtistName string
ArtistMbid string
CoverArtPath string
}
@@ -364,6 +415,7 @@ func (q *Queries) GetAllAlbumsWithDetailsByLibrary(ctx context.Context, libraryI
&i.ReleaseYear,
&i.Mbid,
&i.ArtistName,
&i.ArtistMbid,
&i.CoverArtPath,
); err != nil {
return nil, err
@@ -0,0 +1,51 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: tagging_candidates.sql
package sqlcgen
import (
"context"
)
const deleteTaggingCandidates = `-- name: DeleteTaggingCandidates :exec
DELETE FROM tagging_candidates
WHERE group_key = ?
`
func (q *Queries) DeleteTaggingCandidates(ctx context.Context, groupKey string) error {
_, err := q.db.ExecContext(ctx, deleteTaggingCandidates, groupKey)
return err
}
const getTaggingCandidates = `-- name: GetTaggingCandidates :one
SELECT candidates FROM tagging_candidates
WHERE group_key = ?
LIMIT 1
`
func (q *Queries) GetTaggingCandidates(ctx context.Context, groupKey string) (string, error) {
row := q.db.QueryRowContext(ctx, getTaggingCandidates, groupKey)
var candidates string
err := row.Scan(&candidates)
return candidates, err
}
const upsertTaggingCandidates = `-- name: UpsertTaggingCandidates :exec
INSERT INTO tagging_candidates (group_key, candidates, computed_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(group_key) DO UPDATE SET
candidates = excluded.candidates,
computed_at = excluded.computed_at
`
type UpsertTaggingCandidatesParams struct {
GroupKey string
Candidates string
}
func (q *Queries) UpsertTaggingCandidates(ctx context.Context, arg UpsertTaggingCandidatesParams) error {
_, err := q.db.ExecContext(ctx, upsertTaggingCandidates, arg.GroupKey, arg.Candidates)
return err
}
@@ -127,7 +127,7 @@ SELECT
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,
CAST(COALESCE((SELECT af.file_path FROM audio_files af WHERE af.group_key = ti.group_key AND af.group_key != '' LIMIT 1), '') AS TEXT) AS sample_file_path,
ti.track_count,
ti.album_name,
ti.album_artist,
@@ -543,7 +543,7 @@ SELECT
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,
CAST(COALESCE((SELECT af.file_path FROM audio_files af WHERE af.group_key = ti.group_key AND af.group_key != '' LIMIT 1), '') AS TEXT) AS sample_file_path,
ti.track_count,
ti.album_name,
ti.album_artist,