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:
+602
-10
@@ -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(
|
||||
|
||||
@@ -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, " ") + `"`
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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, ¬used, &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()
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user