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