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
+602 -10
View File
@@ -30,13 +30,45 @@ import (
var schemas embed.FS
// DB wraps the SQLite database connection and queries.
//
// Two handles back a single database file. db is the single-writer
// connection (MaxOpenConns 1) used for every write and every
// transaction. readDB is a small multi-connection, query-only pool
// used for standalone reads. Under WAL, readers run concurrently
// with the writer, so a long background write (index build, dump
// patch) no longer blocks interactive searches — the reason searches
// stalled for seconds was that the file was in rollback-journal mode
// with a single shared connection, so any writer locked out readers.
type DB struct {
db *sql.DB
Ctx context.Context
db *sql.DB
readDB *sql.DB
Ctx context.Context
// Queries runs on the single-writer connection. Use it for every
// write and for any read that must observe an uncommitted write made
// earlier in the same logical operation.
Queries *sqlcgen.Queries
logger *slog.Logger
// ReadQueries runs on the query-only WAL read pool, so standalone
// reads proceed concurrently with a long background write instead of
// queueing behind it on the single writer. It observes only
// committed data. In tests (no read pool) it aliases Queries.
ReadQueries *sqlcgen.Queries
logger *slog.Logger
}
// Data-source names. modernc.org/sqlite only honours PRAGMAs passed
// as `_pragma=name(value)` — the mattn-style `_journal_mode=WAL`
// form is silently ignored, which is why WAL was never actually on.
const (
writeDSNParams = "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)"
readDSNParams = "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)" +
"&_pragma=query_only(true)&_pragma=synchronous(NORMAL)" +
"&_pragma=cache_size(-8000)&_pragma=mmap_size(67108864)"
// readPoolConns bounds concurrent read connections. A handful is
// plenty for interactive search + art/lookup fan-out and keeps WAL
// reader overhead small.
readPoolConns = 4
)
// NewDB opens the database and applies schema migrations.
func NewDB(logger *slog.Logger) (*DB, error) {
defer profiling.TimeOp(logger, "database.NewDB")()
@@ -52,7 +84,7 @@ func NewDB(logger *slog.Logger) (*DB, error) {
logger.Debug("opening sqlite database", "filepath", sqliteDBFilePath)
db, err := sql.Open("sqlite", sqliteDBFilePath+"?_busy_timeout=5000&_journal_mode=WAL")
db, err := sql.Open("sqlite", sqliteDBFilePath+writeDSNParams)
if err != nil {
return nil, fmt.Errorf("could not connect to sqlite database: %w", err)
}
@@ -126,14 +158,37 @@ func NewDB(logger *slog.Logger) (*DB, error) {
// Get generated queries
queries := sqlcgen.New(db)
// Open a separate query-only read pool. The write handle above
// has already converted the file to WAL, so these connections read
// a consistent snapshot concurrently with in-flight writes.
readDB, err := sql.Open("sqlite", sqliteDBFilePath+readDSNParams)
if err != nil {
return nil, fmt.Errorf("could not open read pool: %w", err)
}
readDB.SetMaxOpenConns(readPoolConns)
return &DB{
db: db,
Ctx: dbCtx,
Queries: queries,
logger: logger,
db: db,
readDB: readDB,
Ctx: dbCtx,
Queries: queries,
ReadQueries: sqlcgen.New(readDB),
logger: logger,
}, err
}
// reader returns the handle standalone reads should use: the
// query-only read pool when present, else the write handle (tests
// share one in-memory connection, which cannot be reopened).
func (d *DB) reader() *sql.DB {
if d.readDB != nil {
return d.readDB
}
return d.db
}
// BeginTx starts a new database transaction.
func (d *DB) BeginTx() (*sql.Tx, error) {
return d.db.BeginTx(d.Ctx, nil)
@@ -144,9 +199,20 @@ func (d *DB) ExecContext(query string, args ...any) (sql.Result, error) {
return d.db.ExecContext(d.Ctx, query, args...)
}
// QueryContext executes a query that returns rows.
// QueryContext executes a query that returns rows. Reads run on the
// query-only read pool so they proceed concurrently with writes under
// WAL instead of queueing behind the single writer connection.
func (d *DB) QueryContext(query string, args ...any) (*sql.Rows, error) {
return d.db.QueryContext(d.Ctx, query, args...)
return d.reader().QueryContext(d.Ctx, query, args...)
}
// QueryContextWith executes a query that returns rows using a
// caller-supplied context instead of the DB's lifecycle context.
// This lets an individual query (e.g. a superseded search) be
// cancelled independently. Like QueryContext it runs on the read
// pool.
func (d *DB) QueryContextWith(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
return d.reader().QueryContext(ctx, query, args...)
}
// Logger returns the structured logger bound to this DB. Callers can
@@ -976,6 +1042,468 @@ func runMigrations(
}
}
if version < 37 { //nolint:mnd
if err := migration37ExploreFTSDiacritics(ctx, db, logger); err != nil {
return err
}
}
if version < 38 { //nolint:mnd
if err := migration38TaggingCandidates(ctx, db, logger); err != nil {
return err
}
}
if version < 39 { //nolint:mnd
if err := migration39LyricsIndex(ctx, db, logger); err != nil {
return err
}
}
if version < 40 { //nolint:mnd
if err := migration40ExploreExactMatchIndexes(ctx, db, logger); err != nil {
return err
}
}
if version < 41 { //nolint:mnd
if err := migration41ExploreChampionFTS(ctx, db, logger); err != nil {
return err
}
}
if version < 42 { //nolint:mnd
if err := migration42ReleaseToRG(ctx, db, logger); err != nil {
return err
}
}
if version < 43 { //nolint:mnd
if err := migration43MergeArtistCredits(ctx, db, logger); err != nil {
return err
}
}
if version < 44 { //nolint:mnd
if err := migration44ExploreCAAReleaseIndex(ctx, db, logger); err != nil {
return err
}
}
if version < 45 { //nolint:mnd
if err := migration45Analyze(ctx, db, logger); err != nil {
return err
}
}
if version < 46 { //nolint:mnd
if err := migration46SmartSnapshot(ctx, db, logger); err != nil {
return err
}
}
return nil
}
// migration46SmartSnapshot adds the smart_snapshot_at column to the
// playlists table. Smart playlists now materialize their evaluated
// membership into playlist_tracks and only re-evaluate on demand; the
// timestamp records when that snapshot was last taken (NULL means the
// playlist has never been materialized, so it is backfilled on first
// open).
func migration46SmartSnapshot(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 46: smart playlist snapshot column")
if _, err := db.ExecContext(ctx,
`ALTER TABLE playlists
ADD COLUMN smart_snapshot_at DATETIME`,
); err != nil {
if !isDuplicateColumnErr(err) {
return fmt.Errorf(
"migration 46: could not add smart_snapshot_at column: %w",
err,
)
}
}
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 46",
); err != nil {
return fmt.Errorf(
"migration 46: set user_version: %w", err,
)
}
logger.Info("migration 46 complete")
return nil
}
// migration45Analyze runs ANALYZE so SQLite's query planner has real
// table/index statistics. Without stats the planner guesses from row
// counts alone and mis-chose indexes on the ~2M-row explore_index — e.g.
// the top-result parent-release lookup scanned all 400k release_group
// rows via idx_explore_index_entity_pop instead of seeking the new
// idx_explore_caa_release, costing seconds per search. ANALYZE populates
// sqlite_stat1 (a one-time ~1.5s scan) and the planner then picks the
// right index for that query and every other query on these large tables.
func migration45Analyze(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 45: ANALYZE for query planner statistics")
if _, err := db.ExecContext(ctx, "ANALYZE"); err != nil {
return fmt.Errorf("migration 45: analyze: %w", err)
}
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 45"); err != nil {
return fmt.Errorf("migration 45: set user_version: %w", err)
}
logger.Info("migration 45 complete")
return nil
}
// migration44ExploreCAAReleaseIndex adds a partial index on
// caa_release_mbid so the top-result resolver's parent-release-group
// lookup (SearchIndex.ReleaseGroupMBIDsForCAAReleaseMBIDs) seeks the
// index instead of scanning every release_group row in explore_index
// (~150k) on the hot search path. The index is partial, mirroring the
// query's own filter (entity_type = 'release_group' AND
// caa_release_mbid is non-empty), so it stays small and covers exactly the
// rows that lookup can match. Without it, a generic query whose top
// results include recordings with cover art (e.g. "big") spends
// seconds in this scan.
func migration44ExploreCAAReleaseIndex(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 44: explore caa_release_mbid index")
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_explore_caa_release
ON explore_index(caa_release_mbid)
WHERE entity_type = 'release_group' AND caa_release_mbid != ''
`); err != nil {
return fmt.Errorf("migration 44: create caa_release index: %w", err)
}
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 44"); err != nil {
return fmt.Errorf("migration 44: set user_version: %w", err)
}
logger.Info("migration 44 complete")
return nil
}
// migration43MergeArtistCredits repairs artist rows that were created
// from full credit strings. Before the scanner resolved a track's
// primary artist, a credit like "Lana Del Rey ft. Sean Lennon" was
// stored as its own artists row and stamped with the primary artist's
// single MBID — so one MusicBrainz artist fanned out into many rows that
// shared an MBID, and the explore index (last-write-wins per MBID) then
// displayed a featured-credit string as the artist's name.
//
// This collapses every set of artists rows that share an MBID into the
// one "clean" member (a name with no featuring clause), repoints the
// artist_credit_artist links, deletes the redundant rows, and refreshes
// the explore index's artist titles from the survivors. Clusters with
// no clean member (all names carry a marker) are left untouched.
func migration43MergeArtistCredits(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 43: merge credit-string artists")
// Map each redundant artist row to the clean canonical row for its
// MBID. "Clean" = a name carrying no featuring marker; the lowest
// id among those is the canonical survivor.
if _, err := db.ExecContext(ctx, `
CREATE TEMP TABLE artist_merge_map AS
SELECT a.id AS dirty_id, canon.canon_id AS canon_id
FROM artists a
JOIN (
SELECT mbid, MIN(id) AS canon_id
FROM artists
WHERE mbid IS NOT NULL AND mbid != ''
AND lower(name) NOT LIKE '% feat %'
AND lower(name) NOT LIKE '% feat. %'
AND lower(name) NOT LIKE '% featuring %'
AND lower(name) NOT LIKE '% ft %'
AND lower(name) NOT LIKE '% ft. %'
GROUP BY mbid
) canon ON canon.mbid = a.mbid
WHERE a.id != canon.canon_id
`); err != nil {
return fmt.Errorf("migration 43: build merge map: %w", err)
}
// Drop links that would collide with an existing (canonical, credit)
// link after repointing — the unique index would otherwise reject
// the UPDATE.
if _, err := db.ExecContext(ctx, `
DELETE FROM artist_credit_artist
WHERE id IN (
SELECT aca.id
FROM artist_credit_artist aca
JOIN artist_merge_map m ON m.dirty_id = aca.artist_id
WHERE EXISTS (
SELECT 1 FROM artist_credit_artist keep
WHERE keep.artist_id = m.canon_id
AND keep.credit_id = aca.credit_id
)
)
`); err != nil {
return fmt.Errorf("migration 43: prune colliding links: %w", err)
}
// Repoint surviving links to the canonical artist.
if _, err := db.ExecContext(ctx, `
UPDATE artist_credit_artist
SET artist_id = (
SELECT canon_id FROM artist_merge_map
WHERE dirty_id = artist_credit_artist.artist_id
)
WHERE artist_id IN (SELECT dirty_id FROM artist_merge_map)
`); err != nil {
return fmt.Errorf("migration 43: repoint links: %w", err)
}
// Remove the now-orphaned credit-string artist rows.
if _, err := db.ExecContext(ctx, `
DELETE FROM artists WHERE id IN (SELECT dirty_id FROM artist_merge_map)
`); err != nil {
return fmt.Errorf("migration 43: delete merged artists: %w", err)
}
// Refresh explore-index artist rows from the surviving library
// artists so their (previously clobbered) titles show the clean
// name. The AFTER UPDATE trigger keeps explore_index_fts in sync.
// Only rows backed by a library artist are touched; dump-only rows
// are left alone.
if _, err := db.ExecContext(ctx, `
UPDATE explore_index
SET title = (
SELECT name FROM artists
WHERE artists.mbid = explore_index.mbid ORDER BY id LIMIT 1),
artist_name = (
SELECT name FROM artists
WHERE artists.mbid = explore_index.mbid ORDER BY id LIMIT 1),
local_artist_id = (
SELECT id FROM artists
WHERE artists.mbid = explore_index.mbid ORDER BY id LIMIT 1)
WHERE entity_type = 'artist'
AND EXISTS (SELECT 1 FROM artists WHERE artists.mbid = explore_index.mbid)
`); err != nil {
return fmt.Errorf("migration 43: refresh explore titles: %w", err)
}
if _, err := db.ExecContext(ctx, "DROP TABLE IF EXISTS artist_merge_map"); err != nil {
return fmt.Errorf("migration 43: drop temp table: %w", err)
}
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 43"); err != nil {
return fmt.Errorf("migration 43: set user_version: %w", err)
}
logger.Info("migration 43 complete")
return nil
}
// migration42ReleaseToRG creates the release_to_rg mapping table: for
// every release under an indexed release group, which release-group it
// belongs to. It is populated from the canonical dump during a full
// import (the mapping is otherwise in-memory only and discarded). The
// incremental-dump popularity refresh uses it to roll per-release listen
// deltas up to their release group, so album popularity stays fresh
// without any API call.
func migration42ReleaseToRG(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 42: release_to_rg table")
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS release_to_rg (
release_mbid TEXT PRIMARY KEY,
rg_mbid TEXT NOT NULL
) WITHOUT ROWID
`); err != nil {
return fmt.Errorf("migration 42: create release_to_rg: %w", err)
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 42",
); err != nil {
return fmt.Errorf("migration 42: set user_version: %w", err)
}
logger.Info("migration 42 complete")
return nil
}
// migration41ExploreChampionFTS creates the "champion" full-text index:
// a second external-content FTS5 over explore_index that holds only the
// high-popularity / owned rows. Short generic prefixes ("the", "a")
// match hundreds of thousands of rows in the full index, and the
// popularity-blended ORDER BY must score every one of them — seconds of
// work. Routing those queries at the champion index instead scores only
// the ~90k rows that could plausibly win, cutting the query from seconds
// to tens of milliseconds. The table is created empty here; the search
// index populates it at runtime (see SearchIndex.RebuildChampionIndex)
// because the row set derives from popularity, which changes as the
// index is (re)built.
func migration41ExploreChampionFTS(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 41: explore champion FTS")
if _, err := db.ExecContext(ctx, `
CREATE VIRTUAL TABLE IF NOT EXISTS explore_champion_fts USING fts5(
title, artist_name, aliases,
content='explore_index',
content_rowid='id',
tokenize='unicode61 remove_diacritics 2'
)
`); err != nil {
return fmt.Errorf("migration 41: create champion fts: %w", err)
}
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 41"); err != nil {
return fmt.Errorf("migration 41: set user_version: %w", err)
}
logger.Info("migration 41 complete")
return nil
}
// migration39LyricsIndex creates the contentless FTS5 lyrics_index
// (see lyrics_index.sql) and back-populates it from any recordings
// that already have embedded lyrics, so lyric search works on
// existing libraries without waiting for a rescan.
func migration39LyricsIndex(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 39: lyrics_index FTS")
if _, err := db.ExecContext(ctx, `
CREATE VIRTUAL TABLE IF NOT EXISTS lyrics_index USING fts5(
lyrics,
content='',
contentless_delete=1,
tokenize='unicode61 remove_diacritics 2'
)
`); err != nil {
return fmt.Errorf("migration 39: create lyrics_index: %w", err)
}
// Back-populate from recordings that already carry lyrics. The
// rowid is the recording id so it stays stable across rebuilds.
if _, err := db.ExecContext(ctx, `
INSERT INTO lyrics_index(rowid, lyrics)
SELECT id, lyrics
FROM recordings
WHERE lyrics IS NOT NULL AND lyrics != ''
`); err != nil {
return fmt.Errorf("migration 39: populate lyrics_index: %w", err)
}
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 39"); err != nil {
return fmt.Errorf("migration 39: set user_version: %w", err)
}
logger.Info("migration 39 complete")
return nil
}
// migration40ExploreExactMatchIndexes adds partial expression indexes
// on LOWER(title) and LOWER(artist_name) so the interactive top-result
// resolver's exact-match lookup (SearchIndex.ExactMatches) seeks the
// index instead of scanning all ~240k explore_index rows on every
// keystroke. The indexes are partial (WHERE popularity > 0) because
// that lookup always filters on popularity, keeping them small; the
// UNION-of-equalities query shape in ExactMatches is what lets SQLite
// use them (an OR across the two columns forces a scan instead).
func migration40ExploreExactMatchIndexes(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 40: explore exact-match indexes")
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_explore_title_lower
ON explore_index(LOWER(title))
WHERE popularity > 0
`); err != nil {
return fmt.Errorf("migration 40: create title index: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_explore_artist_lower
ON explore_index(LOWER(artist_name))
WHERE popularity > 0
`); err != nil {
return fmt.Errorf("migration 40: create artist index: %w", err)
}
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 40"); err != nil {
return fmt.Errorf("migration 40: set user_version: %w", err)
}
logger.Info("migration 40 complete")
return nil
}
// migration38TaggingCandidates creates the tagging_candidates table —
// a durable per-group store for the scored candidate list so it is
// computed once and reused across restarts instead of re-hitting
// MusicBrainz every session (see tagging_candidates.sql). A plain
// CREATE TABLE IF NOT EXISTS is safe on both fresh and existing DBs.
func migration38TaggingCandidates(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 38: tagging_candidates")
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS tagging_candidates (
group_key TEXT PRIMARY KEY,
candidates TEXT NOT NULL,
computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(group_key) REFERENCES tagging_items(group_key) ON DELETE CASCADE
)
`); err != nil {
return fmt.Errorf("migration 38: create tagging_candidates: %w", err)
}
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 38"); err != nil {
return fmt.Errorf("migration 38: set user_version: %w", err)
}
logger.Info("migration 38 complete")
return nil
}
@@ -1010,6 +1538,70 @@ func migration36ClearedAt(
return nil
}
// migration37ExploreFTSDiacritics rebuilds explore_index_fts with the
// "unicode61 remove_diacritics 2" tokeniser so accented queries match
// their unaccented forms (e.g. "beyonce" finds "Beyoncé"), matching the
// library search_index tokeniser. The original table (migration 26)
// was created with the default tokeniser, which does not fold
// diacritics.
//
// Because explore_index_fts is an external-content table over
// explore_index, the rebuild repopulates from the existing content
// rows — no data loss and no need to re-run the expensive tiered index
// build.
func migration37ExploreFTSDiacritics(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 37: explore_index_fts diacritic folding")
// Drop the sync triggers and the FTS table, then recreate both.
// The triggers must go first — they reference the FTS table.
stmts := []string{
`DROP TRIGGER IF EXISTS explore_index_ai`,
`DROP TRIGGER IF EXISTS explore_index_ad`,
`DROP TRIGGER IF EXISTS explore_index_au`,
`DROP TABLE IF EXISTS explore_index_fts`,
`CREATE VIRTUAL TABLE explore_index_fts USING fts5(
title, artist_name, aliases,
content='explore_index',
content_rowid='id',
tokenize='unicode61 remove_diacritics 2'
)`,
`CREATE TRIGGER explore_index_ai AFTER INSERT ON explore_index BEGIN
INSERT INTO explore_index_fts(rowid, title, artist_name, aliases)
VALUES (new.id, new.title, new.artist_name, new.aliases);
END`,
`CREATE TRIGGER explore_index_ad AFTER DELETE ON explore_index BEGIN
INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name, aliases)
VALUES ('delete', old.id, old.title, old.artist_name, old.aliases);
END`,
`CREATE TRIGGER explore_index_au AFTER UPDATE ON explore_index BEGIN
INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name, aliases)
VALUES ('delete', old.id, old.title, old.artist_name, old.aliases);
INSERT INTO explore_index_fts(rowid, title, artist_name, aliases)
VALUES (new.id, new.title, new.artist_name, new.aliases);
END`,
// Repopulate the FTS index from the content table.
`INSERT INTO explore_index_fts(explore_index_fts) VALUES('rebuild')`,
}
for _, stmt := range stmts {
if _, err := db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("migration 37: %w", err)
}
}
if _, err := db.ExecContext(ctx, "PRAGMA user_version = 37"); err != nil {
return fmt.Errorf("migration 37: set user_version: %w", err)
}
logger.Info("migration 37 complete")
return nil
}
// backfills it from file_path, creates the basename index, and
// populates the FTS5 search_index table.
func migration2BasenameAndFTS(
+295
View File
@@ -0,0 +1,295 @@
package database
import (
"fmt"
"strings"
"unicode"
)
// LyricsHit is a single result from a lyric-fragment search: the
// matched recording plus enough metadata to render and play it.
type LyricsHit struct {
RecordingID int64
FilePath string
LengthMilliseconds int64
Title string
Artist string
Album string
}
// SearchLyrics finds recordings whose lyrics match the given query,
// ranked by FTS5 relevance. The query is treated as a phrase so a
// fragment like "hello darkness my old friend" matches consecutive
// words rather than each word independently. Returns nil for an
// empty query.
func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, nil
}
if limit <= 0 {
limit = 25
}
ftsQuery := buildLyricsPhraseQuery(query)
if ftsQuery == "" {
return nil, nil
}
// Map the matched recording (lyrics_index.rowid == recordings.id)
// to a representative playable file via the lowest audio_files id,
// then to the track_metadata VIEW for display fields.
//
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
rows, err := d.db.QueryContext(d.Ctx, `
SELECT
r.id,
tm.file_path,
tm.length_milliseconds,
tm.title,
tm.artist_name,
tm.album
FROM lyrics_index li
JOIN recordings r ON r.id = li.rowid
JOIN (
SELECT recording_id, MIN(id) AS af_id
FROM audio_files
GROUP BY recording_id
) af ON af.recording_id = r.id
JOIN track_metadata tm ON tm.id = af.af_id
WHERE lyrics_index MATCH ?
ORDER BY rank
LIMIT ?
`, ftsQuery, limit)
if err != nil {
return nil, fmt.Errorf("lyrics search failed: %w", err)
}
defer func() { _ = rows.Close() }()
var results []LyricsHit
for rows.Next() {
var h LyricsHit
if err := rows.Scan(
&h.RecordingID,
&h.FilePath,
&h.LengthMilliseconds,
&h.Title,
&h.Artist,
&h.Album,
); err != nil {
return nil, fmt.Errorf("could not scan lyrics hit: %w", err)
}
results = append(results, h)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("lyrics hit iteration error: %w", err)
}
return results, nil
}
// GetRecordingLyrics returns the stored lyrics for a recording, or
// an empty string if none are stored.
func (d *DB) GetRecordingLyrics(recordingID int64) (string, error) {
var lyrics string
err := d.db.QueryRowContext(d.Ctx,
"SELECT COALESCE(lyrics, '') FROM recordings WHERE id = ?",
recordingID,
).Scan(&lyrics)
if err != nil {
return "", fmt.Errorf("could not read recording lyrics: %w", err)
}
return lyrics, nil
}
// SetRecordingLyrics writes lyrics onto a recording and keeps the FTS
// lyrics_index in sync (delete + reinsert the single row). Used by
// the LRCLIB backfill to persist fetched lyrics. Passing an empty
// string clears both the column and the index entry.
func (d *DB) SetRecordingLyrics(recordingID int64, lyrics string) error {
if _, err := d.db.ExecContext(d.Ctx,
"UPDATE recordings SET lyrics = ? WHERE id = ?",
lyrics, recordingID,
); err != nil {
return fmt.Errorf("could not update recording lyrics: %w", err)
}
return d.upsertLyricsIndex(recordingID, lyrics)
}
// upsertLyricsIndex refreshes a single recording's entry in the
// contentless lyrics_index. contentless_delete=1 makes the DELETE
// valid; an empty lyrics string leaves the row deleted.
func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error {
if _, err := d.db.ExecContext(d.Ctx,
"DELETE FROM lyrics_index WHERE rowid = ?", recordingID,
); err != nil {
return fmt.Errorf("could not delete lyrics_index row: %w", err)
}
if strings.TrimSpace(lyrics) == "" {
return nil
}
// SAFETY: FTS5 virtual table INSERT unsupported by sqlc. All values parameterized.
if _, err := d.db.ExecContext(d.Ctx,
"INSERT INTO lyrics_index(rowid, lyrics) VALUES (?, ?)",
recordingID, lyrics,
); err != nil {
return fmt.Errorf("could not insert lyrics_index row: %w", err)
}
return nil
}
// RebuildLyricsIndex repopulates lyrics_index from scratch using the
// current recordings table. Cheap for a personal library and safe to
// run after every scan.
func (d *DB) RebuildLyricsIndex() error {
if _, err := d.db.ExecContext(d.Ctx,
"DELETE FROM lyrics_index",
); err != nil {
return fmt.Errorf("could not clear lyrics_index: %w", err)
}
// SAFETY: FTS5 virtual table INSERT unsupported by sqlc. Values sourced from recordings; no user input.
if _, err := d.db.ExecContext(d.Ctx, `
INSERT INTO lyrics_index(rowid, lyrics)
SELECT id, lyrics
FROM recordings
WHERE lyrics IS NOT NULL AND lyrics != ''
`); err != nil {
return fmt.Errorf("could not rebuild lyrics_index: %w", err)
}
return nil
}
// RecordingsMissingLyrics returns recordings that have no stored
// lyrics but do carry the artist/title/duration needed to look them
// up from an external provider. Used by the LRCLIB backfill. The
// limit bounds each batch so the backfill can be run incrementally.
func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) {
if limit <= 0 {
limit = 200
}
rows, err := d.db.QueryContext(d.Ctx, `
SELECT
r.id,
COALESCE(r.name, ''),
COALESCE(ac.text, ''),
COALESCE(rg.name, ''),
MIN(af.length_milliseconds)
FROM recordings r
JOIN audio_files af ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON rgr.recording_id = r.id
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
WHERE (r.lyrics IS NULL OR r.lyrics = '')
AND r.name IS NOT NULL AND r.name != ''
AND ac.text IS NOT NULL AND ac.text != ''
GROUP BY r.id
LIMIT ?
`, limit)
if err != nil {
return nil, fmt.Errorf("could not query recordings missing lyrics: %w", err)
}
defer func() { _ = rows.Close() }()
var out []LyricsCandidate
for rows.Next() {
var c LyricsCandidate
if err := rows.Scan(
&c.RecordingID, &c.Title, &c.Artist, &c.Album, &c.LengthMilliseconds,
); err != nil {
return nil, fmt.Errorf("could not scan lyrics candidate: %w", err)
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("lyrics candidate iteration error: %w", err)
}
return out, nil
}
// LyricsCandidate identifies a recording that needs its lyrics fetched
// and carries the fields an external provider matches on.
type LyricsCandidate struct {
RecordingID int64
Title string
Artist string
Album string
LengthMilliseconds int64
}
// RecordingLyricLookup returns the provider-match fields (artist,
// title, album, duration) for a single recording, so lyrics can be
// fetched on demand. Returns nil if the recording has no audio file
// or no artist/title to match on.
func (d *DB) RecordingLyricLookup(recordingID int64) (*LyricsCandidate, error) {
var c LyricsCandidate
err := d.db.QueryRowContext(d.Ctx, `
SELECT
r.id,
COALESCE(r.name, ''),
COALESCE(ac.text, ''),
COALESCE(rg.name, ''),
COALESCE(MIN(af.length_milliseconds), 0)
FROM recordings r
JOIN audio_files af ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON rgr.recording_id = r.id
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
WHERE r.id = ?
GROUP BY r.id
`, recordingID).Scan(&c.RecordingID, &c.Title, &c.Artist, &c.Album, &c.LengthMilliseconds)
if err != nil {
return nil, fmt.Errorf("could not look up recording for lyrics: %w", err)
}
if c.Title == "" || c.Artist == "" {
return nil, nil
}
return &c, nil
}
// buildLyricsPhraseQuery turns a user's lyric fragment into an FTS5
// phrase query — a single double-quoted string of tokens — so the
// words must appear adjacently ("hello darkness my old friend"),
// which is what a lyric search means. All non-alphanumeric runes are
// treated as separators (matching the unicode61 tokeniser), so no
// user character can break out of the quoted phrase. Returns "" when
// the fragment has no searchable tokens.
func buildLyricsPhraseQuery(query string) string {
fields := strings.FieldsFunc(query, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
})
if len(fields) == 0 {
return ""
}
return `"` + strings.Join(fields, " ") + `"`
}
+252
View File
@@ -0,0 +1,252 @@
package database
import (
"testing"
)
// seedLyricsTrack inserts the minimal FK chain (artist_credit →
// recording → audio_file → release_group link) for one track with the
// given lyrics, so lyric-search tests have realistic joins.
func seedLyricsTrack(
t *testing.T,
db *DB,
id int64,
title, artist, album, lyrics string,
lenMs int64,
) {
t.Helper()
if _, err := db.ExecContext(
"INSERT OR IGNORE INTO artist_credit (id, text) VALUES (?, ?)", id, artist,
); err != nil {
t.Fatalf("insert artist_credit: %v", err)
}
if _, err := db.ExecContext(
"INSERT OR IGNORE INTO release_groups (id, name) VALUES (?, ?)", id, album,
); err != nil {
t.Fatalf("insert release_group: %v", err)
}
if _, err := db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id, lyrics) VALUES (?, ?, ?, ?)",
id, title, id, nullableLyrics(lyrics),
); err != nil {
t.Fatalf("insert recording: %v", err)
}
if _, err := db.ExecContext(
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) "+
"VALUES (?, ?, ?, ?, ?)",
id, "/music/track"+itoa(id)+".mp3", lenMs, 0, id,
); err != nil {
t.Fatalf("insert audio_file: %v", err)
}
if _, err := db.ExecContext(
"INSERT INTO release_group_recordings (release_group_id, recording_id) VALUES (?, ?)",
id, id,
); err != nil {
t.Fatalf("insert release_group_recordings: %v", err)
}
}
func nullableLyrics(l string) any {
if l == "" {
return nil
}
return l
}
func itoa(v int64) string {
if v == 0 {
return "0"
}
var b []byte
for v > 0 {
b = append([]byte{byte('0' + v%10)}, b...)
v /= 10
}
return string(b)
}
func TestSearchLyrics(t *testing.T) {
t.Parallel()
db := NewTestDB(t)
seedLyricsTrack(
t,
db,
1,
"The Sound of Silence",
"Simon & Garfunkel",
"Sounds of Silence",
"Hello darkness my old friend\nI've come to talk with you again",
180000,
)
seedLyricsTrack(t, db, 2, "Bohemian Rhapsody", "Queen", "A Night at the Opera",
"Is this the real life? Is this just fantasy?", 354000)
seedLyricsTrack(t, db, 3, "Instrumental Track", "Some Artist", "Some Album",
"", 200000) // no lyrics — must never appear in results
if err := db.RebuildLyricsIndex(); err != nil {
t.Fatalf("RebuildLyricsIndex: %v", err)
}
t.Run("phrase match returns the right track with metadata", func(t *testing.T) {
t.Parallel()
hits, err := db.SearchLyrics("hello darkness my old friend", 10)
if err != nil {
t.Fatalf("SearchLyrics: %v", err)
}
if len(hits) != 1 {
t.Fatalf("expected 1 hit, got %d: %+v", len(hits), hits)
}
h := hits[0]
if h.RecordingID != 1 {
t.Errorf("RecordingID = %d, want 1", h.RecordingID)
}
if h.Title != "The Sound of Silence" {
t.Errorf("Title = %q, want The Sound of Silence", h.Title)
}
if h.Artist != "Simon & Garfunkel" {
t.Errorf("Artist = %q, want Simon & Garfunkel", h.Artist)
}
if h.Album != "Sounds of Silence" {
t.Errorf("Album = %q, want Sounds of Silence", h.Album)
}
if h.FilePath == "" {
t.Error("FilePath is empty; expected a playable path")
}
})
t.Run("adjacency: scrambled words do not match as a phrase", func(t *testing.T) {
t.Parallel()
hits, err := db.SearchLyrics("friend old darkness", 10)
if err != nil {
t.Fatalf("SearchLyrics: %v", err)
}
if len(hits) != 0 {
t.Errorf("expected 0 phrase hits for scrambled words, got %d", len(hits))
}
})
t.Run("empty query returns nil", func(t *testing.T) {
t.Parallel()
hits, err := db.SearchLyrics(" ", 10)
if err != nil {
t.Fatalf("SearchLyrics: %v", err)
}
if hits != nil {
t.Errorf("expected nil for empty query, got %+v", hits)
}
})
t.Run("no match returns no hits", func(t *testing.T) {
t.Parallel()
hits, err := db.SearchLyrics("this phrase appears in no song", 10)
if err != nil {
t.Fatalf("SearchLyrics: %v", err)
}
if len(hits) != 0 {
t.Errorf("expected 0 hits, got %d", len(hits))
}
})
}
func TestSetRecordingLyricsUpdatesIndex(t *testing.T) {
t.Parallel()
db := NewTestDB(t)
// Track starts with no lyrics.
seedLyricsTrack(t, db, 1, "Yesterday", "The Beatles", "Help!", "", 125000)
if err := db.RebuildLyricsIndex(); err != nil {
t.Fatalf("RebuildLyricsIndex: %v", err)
}
// Nothing indexed yet.
if hits, _ := db.SearchLyrics("yesterday all my troubles", 10); len(hits) != 0 {
t.Fatalf("expected 0 hits before backfill, got %d", len(hits))
}
// Backfill lyrics — should update both the column and the FTS index.
const lyrics = "Yesterday all my troubles seemed so far away"
if err := db.SetRecordingLyrics(1, lyrics); err != nil {
t.Fatalf("SetRecordingLyrics: %v", err)
}
stored, err := db.GetRecordingLyrics(1)
if err != nil {
t.Fatalf("GetRecordingLyrics: %v", err)
}
if stored != lyrics {
t.Errorf("stored lyrics = %q, want %q", stored, lyrics)
}
hits, err := db.SearchLyrics("all my troubles seemed so far away", 10)
if err != nil {
t.Fatalf("SearchLyrics: %v", err)
}
if len(hits) != 1 || hits[0].RecordingID != 1 {
t.Fatalf("expected recording 1 after backfill, got %+v", hits)
}
}
func TestRecordingsMissingLyrics(t *testing.T) {
t.Parallel()
db := NewTestDB(t)
seedLyricsTrack(t, db, 1, "Has Lyrics", "Artist A", "Album A", "some words here", 100000)
seedLyricsTrack(t, db, 2, "No Lyrics", "Artist B", "Album B", "", 200000)
missing, err := db.RecordingsMissingLyrics(50)
if err != nil {
t.Fatalf("RecordingsMissingLyrics: %v", err)
}
if len(missing) != 1 {
t.Fatalf("expected 1 candidate, got %d: %+v", len(missing), missing)
}
c := missing[0]
if c.RecordingID != 2 || c.Title != "No Lyrics" || c.Artist != "Artist B" {
t.Errorf("unexpected candidate: %+v", c)
}
if c.LengthMilliseconds != 200000 {
t.Errorf("LengthMilliseconds = %d, want 200000", c.LengthMilliseconds)
}
// Single-recording lookup mirrors the batch fields.
one, err := db.RecordingLyricLookup(2)
if err != nil {
t.Fatalf("RecordingLyricLookup: %v", err)
}
if one == nil || one.Artist != "Artist B" || one.Album != "Album B" {
t.Errorf("unexpected lookup: %+v", one)
}
}
@@ -64,6 +64,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
@@ -89,6 +100,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
@@ -116,6 +138,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
@@ -141,6 +174,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
@@ -0,0 +1,15 @@
-- 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;
-- name: GetTaggingCandidates :one
SELECT candidates FROM tagging_candidates
WHERE group_key = ?
LIMIT 1;
-- name: DeleteTaggingCandidates :exec
DELETE FROM tagging_candidates
WHERE group_key = ?;
@@ -62,7 +62,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,
@@ -93,7 +93,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,
@@ -0,0 +1,15 @@
-- Contentless FTS5 index over recording lyrics, enabling
-- "search by a lyric fragment → find the song". The rowid is
-- recordings.id. Only recordings with non-empty lyrics are indexed.
--
-- content='' means the lyric text itself is NOT stored a second time
-- (it already lives in recordings.lyrics); the index keeps only the
-- tokenised inverted index, so it stays compact even for large
-- libraries. contentless_delete=1 lets us delete/reinsert a single
-- row when a track's lyrics change (scan update or LRCLIB backfill).
CREATE VIRTUAL TABLE IF NOT EXISTS lyrics_index USING fts5(
lyrics,
content='',
contentless_delete=1,
tokenize='unicode61 remove_diacritics 2'
);
@@ -3,6 +3,7 @@ CREATE TABLE IF NOT EXISTS playlists (
name TEXT NOT NULL,
is_smart INTEGER NOT NULL DEFAULT 0,
smart_rules TEXT,
smart_snapshot_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -0,0 +1,18 @@
-- tagging_candidates durably stores the scored candidate list for a
-- tagging group so it survives process restarts. Without it, only the
-- top score (a single REAL on tagging_items) is persisted; the full
-- candidate list — releases, tracks, alignments — is recomputed every
-- session, re-hitting MusicBrainz whenever the short-TTL http_cache has
-- expired. The blob is written once when a group is first scored and
-- read back on every subsequent open.
--
-- candidates holds the JSON-encoded []autotag.Candidate. ON DELETE
-- CASCADE ties the blob's lifetime to its tagging_items row: when a
-- group's tracks change, the scan path deletes the old group_key row
-- (and SQLite, with foreign_keys = ON, drops the stale blob with it).
CREATE TABLE IF NOT EXISTS tagging_candidates (
group_key TEXT PRIMARY KEY,
candidates TEXT NOT NULL,
computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(group_key) REFERENCES tagging_items(group_key) ON DELETE CASCADE
);
+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,
+50
View File
@@ -283,6 +283,56 @@ func TestCountPendingTaggingItems_UsesPartialIndex(t *testing.T) {
}
}
// TestListPendingFolders_SampleFilePathUsesIndex guards the folder-list
// query's per-row sample_file_path subquery against regressing to a
// full table scan of audio_files. The `AND af.group_key != ”` guard
// is load-bearing: without it SQLite can't prove the partial index
// idx_audio_files_group_key (WHERE group_key != ”) applies, and the
// subquery degrades to O(folders * audio_files) — the difference
// between the review list loading instantly and taking a minute.
func TestListPendingFolders_SampleFilePathUsesIndex(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
rows, err := db.QueryContext(`
EXPLAIN QUERY PLAN
SELECT
ti.group_key,
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)
FROM tagging_items ti
`)
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, &notused, &detail); scanErr != nil {
t.Fatalf("scan: %v", scanErr)
}
plan.WriteString(detail)
plan.WriteString("\n")
}
if !strings.Contains(plan.String(), "idx_audio_files_group_key") {
t.Errorf(
"sample_file_path subquery no longer uses idx_audio_files_group_key "+
"(would full-scan audio_files per folder):\n%s",
plan.String(),
)
}
}
func TestGetTaggingItemAndListAudioFilesInGroup(t *testing.T) {
t.Parallel()
+7 -4
View File
@@ -75,10 +75,13 @@ func NewTestDB(t *testing.T) *DB {
t.Cleanup(func() { _ = db.Close() })
return &DB{
db: db,
Ctx: ctx,
Queries: queries,
logger: slog.Default(),
db: db,
Ctx: ctx,
// The in-memory test DB shares one connection, so reads and
// writes use the same handle; ReadQueries aliases Queries.
Queries: queries,
ReadQueries: queries,
logger: slog.Default(),
}
}