Files
yellowjacket/backend/database/database.go
T
yonlu d73226b173 feat: Library Only mode — toggle, search, artist page, similar artists
Backend:
- Migration 17: similar_artist_map table stores per-artist similar
  artist relationships (source_mbid → similar_mbid + name + score)
- Tier 4 index build now persists similar artists to this table
- GetLibrarySimilarArtists(mbid) queries similar artists filtered
  by JOIN with the artists table (library-only, no API calls)
- Added db field to explore.Service for direct queries

Frontend:
- ExploreSettingsStore with libraryOnly toggle, persisted to
  localStorage
- Top bar toggle button with active/inactive styling
- Explore search: skips full MB/LB pipeline when library-only,
  uses only searchLibraryCache (pure JS, instant)
- Artist detail page: in library-only mode, skips all API calls
  (no top tracks, no top releases, no LB play count, no MB
  artist lookup). Uses library store for discography, calls
  GetLibrarySimilarArtists for similar artists.
- Similar artists section: changed from horizontal scroll to
  wrapping flex layout with collapsible toggle (Show all N)
- Removed debug artist ranking log
2026-03-30 15:36:37 -04:00

2003 lines
51 KiB
Go

// Package database provides SQLite database access.
package database
import (
"context"
"database/sql"
"embed"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/BurntSushi/toml"
_ "modernc.org/sqlite" // Register sqlite driver.
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/profiling"
"yellowjacket/backend/system"
)
//go:generate go tool sqlc generate
//go:embed sql/schemas/*.sql
var schemas embed.FS
// DB wraps the SQLite database connection and queries.
type DB struct {
db *sql.DB
Ctx context.Context
Queries *sqlcgen.Queries
logger *slog.Logger
}
// NewDB opens the database and applies schema migrations.
func NewDB(logger *slog.Logger) (*DB, error) {
defer profiling.TimeOp(logger, "database.NewDB")()
dbCtx := context.Background()
userDataDir, err := system.GetUserDataDirPath()
if err != nil {
return nil, fmt.Errorf("could not get user data directory: %w", err)
}
sqliteDBFilePath := path.Join(userDataDir, "yj.db")
logger.Debug("opening sqlite database", "filepath", sqliteDBFilePath)
db, err := sql.Open("sqlite", sqliteDBFilePath+"?_busy_timeout=5000&_journal_mode=WAL")
if err != nil {
return nil, fmt.Errorf("could not connect to sqlite database: %w", err)
}
db.SetMaxOpenConns(1) // SQLite only supports one writer at a time
if err := applyPRAGMAs(dbCtx, db); err != nil {
return nil, fmt.Errorf("could not apply PRAGMAs: %w", err)
}
// Execute SQL files from the embedded schemas directory
logger.Debug("reading sql schema files from embedded directory")
dirEntries, err := schemas.ReadDir("sql/schemas")
if err != nil {
return nil, fmt.Errorf("could not read schemas directory: %w", err)
}
logger.Debug("executing all sql schema files")
for _, dirEntry := range dirEntries {
if !dirEntry.IsDir() {
filePath := path.Join("sql/schemas", dirEntry.Name())
sqlContent, err := fs.ReadFile(schemas, filePath)
if err != nil {
return nil, fmt.Errorf("could not read file %s: %w", filePath, err)
}
logger.Debug(
"executing sql schema file",
"filepath",
filePath,
"sql",
string(sqlContent),
)
_, err = db.ExecContext(dbCtx, string(sqlContent)) // Execute the SQL
if err != nil {
return nil, fmt.Errorf("error executing sql from file %s: %w", filePath, err)
}
}
}
// Run versioned schema migrations for columns that cannot be
// added with CREATE TABLE IF NOT EXISTS on existing databases.
if err := runMigrations(dbCtx, db, logger, sqliteDBFilePath); err != nil {
return nil, fmt.Errorf(
"could not run schema migrations: %w", err,
)
}
// Remove orphaned playlist_tracks left behind by past deletes
// that ran without foreign key enforcement.
orphanResult, err := db.ExecContext(
dbCtx,
"DELETE FROM playlist_tracks WHERE playlist_id NOT IN (SELECT id FROM playlists)",
)
if err != nil {
logger.Warn(
"could not clean orphaned playlist tracks",
"err", err,
)
} else if n, _ := orphanResult.RowsAffected(); n > 0 {
logger.Info(
"Cleaned orphaned playlist tracks",
"deleted", n,
)
}
// Get generated queries
queries := sqlcgen.New(db)
return &DB{
db: db,
Ctx: dbCtx,
Queries: queries,
logger: logger,
}, err
}
// BeginTx starts a new database transaction.
func (d *DB) BeginTx() (*sql.Tx, error) {
return d.db.BeginTx(d.Ctx, nil)
}
// ExecContext executes a query without returning any rows.
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.
func (d *DB) QueryContext(query string, args ...any) (*sql.Rows, error) {
return d.db.QueryContext(d.Ctx, query, args...)
}
// applyPRAGMAs configures SQLite connection settings. Called by both
// NewDB and NewTestDB to ensure identical behavior.
func applyPRAGMAs(ctx context.Context, db *sql.DB) error {
pragmas := []string{
"PRAGMA foreign_keys = ON",
"PRAGMA synchronous = NORMAL",
"PRAGMA cache_size = -8000",
"PRAGMA mmap_size = 67108864",
}
for _, pragma := range pragmas {
if _, err := db.ExecContext(ctx, pragma); err != nil {
return fmt.Errorf(
"could not apply PRAGMA %q: %w", pragma, err,
)
}
}
return nil
}
// runMigrations applies incremental schema changes using SQLite's
// PRAGMA user_version as the version tracker. Each migration runs
// once and bumps the version so it is never re-applied.
func runMigrations(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
dbPath string,
) error {
var version int
if err := db.QueryRowContext(
ctx, "PRAGMA user_version",
).Scan(&version); err != nil {
return fmt.Errorf(
"could not read user_version: %w", err,
)
}
logger.Debug(
"current schema version",
"user_version", version,
)
// Migration 1: add audio-property columns to audio_files.
if version < 1 {
logger.Info("applying migration 1: audio file properties")
cols := []string{
"sample_rate int NOT NULL DEFAULT 0",
"bit_depth int NOT NULL DEFAULT 0",
"channels int NOT NULL DEFAULT 0",
"bitrate int NOT NULL DEFAULT 0",
"file_size int NOT NULL DEFAULT 0",
}
for _, col := range cols {
stmt := "ALTER TABLE audio_files ADD COLUMN " + col
if _, err := db.ExecContext(ctx, stmt); err != nil {
// Column may already exist on a fresh DB that
// ran the updated CREATE TABLE. SQLite returns
// "duplicate column name" in that case.
if isDuplicateColumnErr(err) {
continue
}
return fmt.Errorf(
"migration 1 failed (%s): %w", col, err,
)
}
}
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 1",
); err != nil {
return fmt.Errorf(
"could not set user_version to 1: %w", err,
)
}
}
// Migration 2: add basename column and populate search index.
if version < 2 {
if err := migration2BasenameAndFTS(
ctx, db, logger,
); err != nil {
return err
}
}
// Migration 3: add UNIQUE constraint to artist_credit_artist.
if version < 3 {
logger.Info(
"applying migration 3: artist_credit_artist unique constraint",
)
// Remove duplicates first (keep lowest ID per pair).
if _, err := db.ExecContext(ctx, `
DELETE FROM artist_credit_artist
WHERE id NOT IN (
SELECT MIN(id)
FROM artist_credit_artist
GROUP BY artist_id, credit_id
)
`); err != nil {
return fmt.Errorf(
"migration 3: could not deduplicate: %w", err,
)
}
if _, err := db.ExecContext(ctx, `
CREATE UNIQUE INDEX IF NOT EXISTS
idx_artist_credit_artist_unique
ON artist_credit_artist(artist_id, credit_id)
`); err != nil {
return fmt.Errorf(
"migration 3: could not create unique index: %w",
err,
)
}
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 3",
); err != nil {
return fmt.Errorf(
"could not set user_version to 3: %w", err,
)
}
logger.Info("migration 3 complete")
}
// Migration 4: create track_metadata VIEW.
if version < 4 {
if err := migration4TrackMetadataView(
ctx, db, logger,
); err != nil {
return err
}
}
// Migration 5: rebuild release_groups with composite unique
// constraint on (name, album_artist_credit_id) instead of
// name alone, so albums with the same name by different
// artists are stored as separate rows.
if version < 5 {
if err := migration5ReleaseGroupCompositeUnique(
ctx, db, logger,
); err != nil {
return err
}
}
// Migration 6: multi-library support.
if version < 6 {
if err := migration6MultiLibrary(
ctx, db, logger, dbPath,
); err != nil {
return err
}
}
// Migration 7: add phantom_file_path to playlist_tracks
// for automatic phantom resolution after library re-scans.
if version < 7 {
if err := migration7PhantomFilePath(
ctx, db, logger,
); err != nil {
return err
}
}
// Migration 8: rebuild FTS5 search_index with
// contentless_delete=1 so individual rows can be deleted.
if version < 8 {
if err := migration8ContentlessDelete(
ctx, db, logger,
); err != nil {
return err
}
}
// Migration 9: add smart playlist columns to playlists.
if version < 9 {
if err := migration9SmartPlaylists(
ctx, db, logger,
); err != nil {
return err
}
}
if version < 10 {
if err := migration10PlayHistory(
ctx, db, logger,
); err != nil {
return err
}
}
// Migration 11: explore_cache table for MusicBrainz/ListenBrainz
// API response caching with TTL expiry and MBID lookups.
if version < 11 {
if err := migration11ExploreCache(
ctx, db, logger,
); err != nil {
return err
}
}
// Migration 12: explore_index + FTS5 for the popularity search
// index. Stores the top albums and tracks from the most popular
// ListenBrainz artists for instant local search.
if version < 12 { //nolint:mnd
if err := migration12ExploreSearchIndex(
ctx, db, logger,
); err != nil {
return err
}
}
// Migration 13: add MusicBrainz ID columns to artists,
// release_groups, and recordings for library↔explore linking.
if version < 13 { //nolint:mnd
if err := migration13MBIDColumns(
ctx, db, logger,
); err != nil {
return err
}
}
// Migration 14: add aliases column to explore_index and rebuild
// the FTS5 virtual table with 3 searchable columns.
if version < 14 { //nolint:mnd
if err := migration14ExploreAliases(
ctx, db, logger,
); err != nil {
return err
}
}
// Migration 15: add in_library and is_similar columns to
// explore_index for personalized search ranking.
if version < 15 { //nolint:mnd
if err := migration15PersonalizationColumns(
ctx, db, logger,
); err != nil {
return err
}
}
// Migration 16: artist_images table for multi-source artist photos.
if version < 16 { //nolint:mnd
if err := migration16ArtistImages(
ctx, db, logger,
); err != nil {
return err
}
}
if version < 17 { //nolint:mnd
if err := migration17SimilarArtistMap(
ctx, db, logger,
); err != nil {
return err
}
}
return nil
}
// migration2BasenameAndFTS adds the basename column to audio_files,
// backfills it from file_path, creates the basename index, and
// populates the FTS5 search_index table.
func migration2BasenameAndFTS(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info(
"applying migration 2: basename column + FTS5 search index",
)
// Add basename column (may already exist on fresh DBs).
if _, err := db.ExecContext(
ctx,
"ALTER TABLE audio_files ADD COLUMN basename text NOT NULL DEFAULT ''",
); err != nil && !isDuplicateColumnErr(err) {
return fmt.Errorf(
"migration 2: could not add basename column: %w",
err,
)
}
// Backfill basename from file_path for existing rows.
// SQLite doesn't have a basename function, so we use
// REPLACE to strip directories by finding everything
// after the last '/'.
if _, err := db.ExecContext(ctx, `
UPDATE audio_files
SET basename = CASE
WHEN INSTR(file_path, '/') > 0
THEN SUBSTR(
file_path,
LENGTH(file_path)
- LENGTH(
REPLACE(file_path, '/', '')
)
+ 1
)
ELSE file_path
END
WHERE basename = ''
`); err != nil {
return fmt.Errorf(
"migration 2: could not backfill basename: %w",
err,
)
}
// Create index (IF NOT EXISTS handles fresh DBs).
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_audio_files_basename
ON audio_files(basename)
`); err != nil {
return fmt.Errorf(
"migration 2: could not create basename index: %w",
err,
)
}
// Populate FTS5 search index from existing data.
if _, err := db.ExecContext(ctx, `
INSERT INTO search_index(rowid, file_path, title, artist, album)
SELECT
af.id,
af.file_path,
COALESCE(r.name, ''),
COALESCE(ac.text, ''),
COALESCE(rg.name, '')
FROM audio_files af
LEFT JOIN recordings r 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 r.id = rgr.recording_id
LEFT JOIN release_groups rg
ON rgr.release_group_id = rg.id
`); err != nil {
return fmt.Errorf(
"migration 2: could not populate search index: %w",
err,
)
}
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 2",
); err != nil {
return fmt.Errorf(
"could not set user_version to 2: %w", err,
)
}
logger.Info("migration 2 complete")
return nil
}
// migration4TrackMetadataView creates the track_metadata VIEW that
// consolidates the 5-table JOIN used by FTS5 search queries.
// Fresh databases get the VIEW from the embedded schema file;
// this migration covers databases created before the VIEW existed.
func migration4TrackMetadataView(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info(
"applying migration 4: track_metadata VIEW",
)
if _, err := db.ExecContext(ctx, `
CREATE VIEW IF NOT EXISTS track_metadata AS
SELECT
af.id,
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size
FROM audio_files af
LEFT JOIN recordings r 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 r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
`); err != nil {
return fmt.Errorf(
"migration 4: could not create track_metadata VIEW: %w",
err,
)
}
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 4",
); err != nil {
return fmt.Errorf(
"could not set user_version to 4: %w", err,
)
}
logger.Info("migration 4 complete")
return nil
}
// migration5ReleaseGroupCompositeUnique rebuilds the release_groups
// table with UNIQUE(name, album_artist_credit_id) instead of
// UNIQUE(name). SQLite cannot ALTER a UNIQUE constraint, so we
// must rebuild the table.
//
// SAFETY: Hand-crafted SQL for schema migration.
func migration5ReleaseGroupCompositeUnique(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info(
"applying migration 5: release_groups composite unique constraint",
)
// Temporarily disable FK checks for table rebuild.
if _, err := db.ExecContext(
ctx, "PRAGMA foreign_keys = OFF",
); err != nil {
return fmt.Errorf(
"migration 5: could not disable foreign keys: %w",
err,
)
}
// Drop the track_metadata VIEW that references release_groups
// so the table rebuild can proceed without SQLite complaining
// about a dangling VIEW reference.
if _, err := db.ExecContext(
ctx, "DROP VIEW IF EXISTS track_metadata",
); err != nil {
return fmt.Errorf(
"migration 5: could not drop track_metadata VIEW: %w",
err,
)
}
// Create new table with composite unique constraint.
if _, err := db.ExecContext(ctx, `
CREATE TABLE release_groups_new (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
cover_art_id INTEGER,
album_artist_credit_id INTEGER,
year INTEGER,
total_tracks INTEGER,
total_discs INTEGER,
FOREIGN KEY(cover_art_id) REFERENCES cover_art(id),
FOREIGN KEY(album_artist_credit_id) REFERENCES artist_credit(id),
UNIQUE(name, album_artist_credit_id)
)
`); err != nil {
return fmt.Errorf(
"migration 5: could not create release_groups_new: %w",
err,
)
}
// Copy all data.
if _, err := db.ExecContext(ctx, `
INSERT INTO release_groups_new
SELECT * FROM release_groups
`); err != nil {
return fmt.Errorf(
"migration 5: could not copy data: %w", err,
)
}
// Drop old table.
if _, err := db.ExecContext(
ctx, "DROP TABLE release_groups",
); err != nil {
return fmt.Errorf(
"migration 5: could not drop old table: %w", err,
)
}
// Rename new table.
if _, err := db.ExecContext(ctx, `
ALTER TABLE release_groups_new
RENAME TO release_groups
`); err != nil {
return fmt.Errorf(
"migration 5: could not rename table: %w", err,
)
}
// Recreate indexes.
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_release_groups_cover_art_id
ON release_groups(cover_art_id)
`); err != nil {
return fmt.Errorf(
"migration 5: could not create cover_art_id index: %w",
err,
)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_release_groups_album_artist_credit_id
ON release_groups(album_artist_credit_id)
`); err != nil {
return fmt.Errorf(
"migration 5: could not create album_artist_credit_id index: %w",
err,
)
}
// Recreate the track_metadata VIEW that was dropped above.
// The definition must match the embedded schema file
// (sql/schemas/track_metadata_view.sql) exactly.
if _, err := db.ExecContext(ctx, `
CREATE VIEW IF NOT EXISTS track_metadata AS
SELECT
af.id,
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size
FROM audio_files af
LEFT JOIN recordings r 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 r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
`); err != nil {
return fmt.Errorf(
"migration 5: could not recreate track_metadata VIEW: %w",
err,
)
}
// Re-enable FK checks.
if _, err := db.ExecContext(
ctx, "PRAGMA foreign_keys = ON",
); err != nil {
return fmt.Errorf(
"migration 5: could not re-enable foreign keys: %w",
err,
)
}
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 5",
); err != nil {
return fmt.Errorf(
"could not set user_version to 5: %w", err,
)
}
logger.Info("migration 5 complete")
return nil
}
// isDuplicateColumnErr returns true when the error is SQLite's
// "duplicate column name" error from an ALTER TABLE ADD COLUMN
// on a column that already exists.
func isDuplicateColumnErr(err error) bool {
return err != nil &&
strings.Contains(
err.Error(), "duplicate column name",
)
}
// backupDatabase copies the database file to a timestamped backup
// before running a destructive migration. Returns the backup path.
func backupDatabase(
dbPath string, logger *slog.Logger,
) (string, error) {
backupPath := dbPath + ".bak." + time.Now().Format("20060102")
src, err := os.Open(dbPath)
if err != nil {
return "", fmt.Errorf(
"could not open database for backup: %w", err,
)
}
defer func() { _ = src.Close() }()
dst, err := os.Create(backupPath)
if err != nil {
return "", fmt.Errorf(
"could not create backup file: %w", err,
)
}
defer func() { _ = dst.Close() }()
if _, err := io.Copy(dst, src); err != nil {
return "", fmt.Errorf(
"could not copy database to backup: %w", err,
)
}
logger.Info("database backup created", "path", backupPath)
return backupPath, nil
}
// migration6MultiLibrary adds multi-library support: creates the
// libraries table, adds library_id FK to audio_files, rebuilds
// playlist_tracks with SET NULL FK and phantom metadata columns,
// and recreates the track_metadata VIEW with library_id.
//
// SAFETY: Hand-crafted SQL for schema migration.
func migration6MultiLibrary(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
dbPath string,
) error {
logger.Info("applying migration 6: multi-library support")
// 1. Backup database BEFORE any changes (skip for in-memory DBs).
if dbPath != "" && dbPath != ":memory:" {
if _, err := backupDatabase(dbPath, logger); err != nil {
return fmt.Errorf(
"migration 6: backup failed: %w", err,
)
}
}
// 2. Read TOML config to get existing library directory.
existingDir := readLibraryDirFromTOML(logger)
// 3. Disable FK checks for table rebuild.
// SAFETY: PRAGMA foreign_keys cannot run inside a transaction.
if _, err := db.ExecContext(
ctx, "PRAGMA foreign_keys = OFF",
); err != nil {
return fmt.Errorf(
"migration 6: could not disable foreign keys: %w",
err,
)
}
// 4. Create libraries table.
// SAFETY: Hand-crafted DDL for new table.
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS libraries (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`); err != nil {
return fmt.Errorf(
"migration 6: could not create libraries table: %w",
err,
)
}
// 5. Insert default library from TOML (if existingDir is not empty).
var defaultLibID int64
if existingDir != "" {
libName := filepath.Base(existingDir)
// SAFETY: Hand-crafted INSERT for migrated default library.
result, err := db.ExecContext(ctx,
"INSERT INTO libraries (name, path) VALUES (?, ?)",
libName, existingDir,
)
if err != nil {
return fmt.Errorf(
"migration 6: could not insert default library: %w",
err,
)
}
defaultLibID, _ = result.LastInsertId()
logger.Info("migrated existing library",
"name", libName,
"path", existingDir,
"id", defaultLibID,
)
}
// 6. Add library_id column to audio_files.
// SAFETY: ALTER TABLE ADD COLUMN with dynamic DEFAULT for backfill.
stmt := fmt.Sprintf(
"ALTER TABLE audio_files ADD COLUMN library_id INTEGER NOT NULL DEFAULT %d",
defaultLibID,
)
if _, err := db.ExecContext(ctx, stmt); err != nil {
if !isDuplicateColumnErr(err) {
return fmt.Errorf(
"migration 6: could not add library_id column: %w",
err,
)
}
}
// 7. Create index on library_id.
// SAFETY: Hand-crafted index for FK performance.
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_audio_files_library_id
ON audio_files(library_id)
`); err != nil {
return fmt.Errorf(
"migration 6: could not create library_id index: %w",
err,
)
}
// 8. Drop track_metadata VIEW before table rebuild.
if _, err := db.ExecContext(
ctx, "DROP VIEW IF EXISTS track_metadata",
); err != nil {
return fmt.Errorf(
"migration 6: could not drop track_metadata VIEW: %w",
err,
)
}
// 9. Rebuild playlist_tracks for SET NULL FK + phantom columns.
// SAFETY: Table rebuild — playlist_tracks changes to SET NULL,
// queue_tracks keeps CASCADE (ephemeral, not rebuilt).
// SAFETY: Hand-crafted DDL for rebuilt playlist_tracks.
if _, err := db.ExecContext(ctx, `
CREATE TABLE playlist_tracks_new (
id INTEGER PRIMARY KEY,
playlist_id INTEGER NOT NULL,
audio_file_id INTEGER,
position INTEGER NOT NULL,
phantom_title TEXT,
phantom_artist TEXT,
phantom_album TEXT,
phantom_duration_ms INTEGER,
phantom_genre TEXT,
phantom_cover_art_path TEXT,
FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE SET NULL
)
`); err != nil {
return fmt.Errorf(
"migration 6: could not create playlist_tracks_new: %w",
err,
)
}
// Copy existing data (phantom columns get NULL).
// SAFETY: Hand-crafted INSERT-SELECT for data migration.
if _, err := db.ExecContext(ctx, `
INSERT INTO playlist_tracks_new (id, playlist_id, audio_file_id, position)
SELECT id, playlist_id, audio_file_id, position FROM playlist_tracks
`); err != nil {
return fmt.Errorf(
"migration 6: could not copy playlist_tracks data: %w",
err,
)
}
// Drop old table.
if _, err := db.ExecContext(
ctx, "DROP TABLE playlist_tracks",
); err != nil {
return fmt.Errorf(
"migration 6: could not drop old playlist_tracks: %w",
err,
)
}
// Rename.
if _, err := db.ExecContext(ctx,
"ALTER TABLE playlist_tracks_new RENAME TO playlist_tracks",
); err != nil {
return fmt.Errorf(
"migration 6: could not rename playlist_tracks_new: %w",
err,
)
}
// Recreate indexes.
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist_id
ON playlist_tracks(playlist_id)
`); err != nil {
return fmt.Errorf(
"migration 6: could not create playlist_id index: %w",
err,
)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_playlist_tracks_audio_file_id
ON playlist_tracks(audio_file_id)
`); err != nil {
return fmt.Errorf(
"migration 6: could not create audio_file_id index: %w",
err,
)
}
// 10. Backfill phantom metadata on existing playlist_tracks
// from audio_files JOINs. Eager population per user decision.
// SAFETY: Hand-crafted UPDATE-FROM-SELECT for phantom backfill.
if _, err := db.ExecContext(ctx, `
UPDATE playlist_tracks SET
phantom_title = sub.title,
phantom_artist = sub.artist,
phantom_album = sub.album,
phantom_duration_ms = sub.duration,
phantom_genre = sub.genre,
phantom_cover_art_path = sub.cover_art_path
FROM (
SELECT
pt.id AS pt_id,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist,
COALESCE(rg.name, '') AS album,
af.length_milliseconds AS duration,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(ca.file_path, '') AS cover_art_path
FROM playlist_tracks pt
JOIN audio_files af ON pt.audio_file_id = af.id
LEFT JOIN recordings r 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 r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
) sub
WHERE playlist_tracks.id = sub.pt_id
`); err != nil {
return fmt.Errorf(
"migration 6: could not backfill phantom metadata: %w",
err,
)
}
// 11. Recreate track_metadata VIEW with library_id.
// SAFETY: Hand-crafted VIEW recreation matching schema file.
if _, err := db.ExecContext(ctx, `
CREATE VIEW IF NOT EXISTS track_metadata AS
SELECT
af.id,
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size,
af.library_id
FROM audio_files af
LEFT JOIN recordings r 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 r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
`); err != nil {
return fmt.Errorf(
"migration 6: could not recreate track_metadata VIEW: %w",
err,
)
}
// 12. Re-enable FK checks.
if _, err := db.ExecContext(
ctx, "PRAGMA foreign_keys = ON",
); err != nil {
return fmt.Errorf(
"migration 6: could not re-enable foreign keys: %w",
err,
)
}
// 13. Remove DirectoryPath from TOML config (libraries table
// is now the source of truth).
if existingDir != "" {
removeLibraryDirFromTOML(logger)
}
// 14. Set version.
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 6",
); err != nil {
return fmt.Errorf(
"could not set user_version to 6: %w", err,
)
}
logger.Info("migration 6 complete")
return nil
}
// migration7PhantomFilePath adds the phantom_file_path column to
// playlist_tracks so that phantom entries can be automatically
// re-linked to audio_files after a library scan.
func migration7PhantomFilePath(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info(
"applying migration 7: phantom_file_path column",
)
// SAFETY: ALTER TABLE ADD COLUMN for new nullable column.
if _, err := db.ExecContext(ctx,
`ALTER TABLE playlist_tracks
ADD COLUMN phantom_file_path TEXT`,
); err != nil {
if !isDuplicateColumnErr(err) {
return fmt.Errorf(
"migration 7: could not add column: %w",
err,
)
}
}
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 7",
); err != nil {
return fmt.Errorf(
"could not set user_version to 7: %w", err,
)
}
logger.Info("migration 7 complete")
return nil
}
// migration8ContentlessDelete rebuilds the FTS5 search_index with
// contentless_delete=1 so that individual rows can be deleted.
// This is a prerequisite for inline tag edit → DB sync in Phase 16.
//
// SAFETY: Hand-crafted SQL for FTS5 schema migration.
func migration8ContentlessDelete(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info(
"applying migration 8: rebuilding FTS5 search_index with contentless_delete=1",
)
// Drop the old contentless FTS5 table (content='' only).
if _, err := db.ExecContext(
ctx, `DROP TABLE IF EXISTS search_index`,
); err != nil {
return fmt.Errorf(
"migration 8: could not drop search_index: %w", err,
)
}
// Recreate with contentless_delete=1 added.
// SAFETY: Must match backend/database/sql/schemas/search_index.sql exactly.
if _, err := db.ExecContext(ctx, `
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
file_path,
title,
artist,
album,
content='',
contentless_delete=1,
tokenize='unicode61 remove_diacritics 2'
)
`); err != nil {
return fmt.Errorf(
"migration 8: could not recreate search_index: %w", err,
)
}
// Repopulate from track_metadata VIEW.
// SAFETY: FTS5 INSERT from VIEW; no user input.
if _, err := db.ExecContext(ctx, `
INSERT INTO search_index(rowid, file_path, title, artist, album)
SELECT id, file_path, title, artist_name, album
FROM track_metadata
`); err != nil {
return fmt.Errorf(
"migration 8: could not repopulate search_index: %w", err,
)
}
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 8",
); err != nil {
return fmt.Errorf(
"migration 8: could not set user_version: %w", err,
)
}
logger.Info("migration 8 complete")
return nil
}
// migration9SmartPlaylists adds the is_smart and smart_rules
// columns to the playlists table for smart playlist support.
func migration9SmartPlaylists(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info(
"applying migration 9: smart playlist columns",
)
if _, err := db.ExecContext(ctx,
`ALTER TABLE playlists
ADD COLUMN is_smart INTEGER NOT NULL DEFAULT 0`,
); err != nil {
if !isDuplicateColumnErr(err) {
return fmt.Errorf(
"migration 9: could not add is_smart column: %w",
err,
)
}
}
if _, err := db.ExecContext(ctx,
`ALTER TABLE playlists
ADD COLUMN smart_rules TEXT`,
); err != nil {
if !isDuplicateColumnErr(err) {
return fmt.Errorf(
"migration 9: could not add smart_rules column: %w",
err,
)
}
}
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 9",
); err != nil {
return fmt.Errorf(
"could not set user_version to 9: %w", err,
)
}
logger.Info("migration 9 complete")
return nil
}
// migration10PlayHistory adds play history tracking:
// - play_history table for timestamped play log
// - play_count and last_played columns on audio_files
// - Recreates track_metadata VIEW to expose the new columns.
func migration10PlayHistory(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info(
"applying migration 10: play history tracking",
)
// 1. Create play_history table.
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS play_history (
id INTEGER PRIMARY KEY,
audio_file_id INTEGER NOT NULL,
played_at DATETIME NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE
)`,
); err != nil {
return fmt.Errorf(
"migration 10: could not create play_history table: %w",
err,
)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_play_history_audio_file_id
ON play_history(audio_file_id)`,
); err != nil {
return fmt.Errorf(
"migration 10: could not create play_history index: %w",
err,
)
}
// 2. Add play_count and last_played columns to audio_files.
if _, err := db.ExecContext(ctx,
`ALTER TABLE audio_files
ADD COLUMN play_count INTEGER NOT NULL DEFAULT 0`,
); err != nil {
if !isDuplicateColumnErr(err) {
return fmt.Errorf(
"migration 10: could not add play_count column: %w",
err,
)
}
}
if _, err := db.ExecContext(ctx,
`ALTER TABLE audio_files
ADD COLUMN last_played DATETIME`,
); err != nil {
if !isDuplicateColumnErr(err) {
return fmt.Errorf(
"migration 10: could not add last_played column: %w",
err,
)
}
}
// 3. Recreate track_metadata VIEW to include play_count and last_played.
if _, err := db.ExecContext(
ctx, "DROP VIEW IF EXISTS track_metadata",
); err != nil {
return fmt.Errorf(
"migration 10: could not drop track_metadata VIEW: %w",
err,
)
}
if _, err := db.ExecContext(ctx, `
CREATE VIEW IF NOT EXISTS track_metadata AS
SELECT
af.id,
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size,
af.library_id,
af.play_count,
af.last_played
FROM audio_files af
LEFT JOIN recordings r 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 r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id`,
); err != nil {
return fmt.Errorf(
"migration 10: could not create track_metadata VIEW: %w",
err,
)
}
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 10",
); err != nil {
return fmt.Errorf(
"could not set user_version to 10: %w", err,
)
}
logger.Info("migration 10 complete")
return nil
}
// migration11ExploreCache creates the explore_cache table for
// MusicBrainz and ListenBrainz API response caching. The table
// stores raw JSON keyed by URL with TTL-based expiry and optional
// MBID columns for future autotagging lookups.
func migration11ExploreCache(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info(
"applying migration 11: explore_cache table",
)
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS explore_cache (
url_key TEXT PRIMARY KEY,
response TEXT NOT NULL,
mbid TEXT,
entity_type TEXT,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`); err != nil {
return fmt.Errorf(
"migration 11: could not create explore_cache table: %w",
err,
)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_explore_cache_expires
ON explore_cache(expires_at)
`); err != nil {
return fmt.Errorf(
"migration 11: could not create expires index: %w",
err,
)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_explore_cache_mbid
ON explore_cache(mbid)
`); err != nil {
return fmt.Errorf(
"migration 11: could not create mbid index: %w",
err,
)
}
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 11",
); err != nil {
return fmt.Errorf(
"could not set user_version to 11: %w", err,
)
}
logger.Info("migration 11 complete")
return nil
}
// migration12ExploreSearchIndex creates the explore_index table,
// the FTS5 virtual table for full-text search, sync triggers, and
// the explore_index_meta table for build tracking.
func migration12ExploreSearchIndex(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 12: explore search index")
// Content table — slim denormalized rows for search.
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS explore_index (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL,
mbid TEXT NOT NULL,
title TEXT NOT NULL,
artist_name TEXT NOT NULL,
artist_mbid TEXT NOT NULL,
popularity INTEGER NOT NULL DEFAULT 0,
extra_json TEXT
)
`); err != nil {
return fmt.Errorf("migration 12: create explore_index: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE UNIQUE INDEX IF NOT EXISTS idx_explore_index_mbid
ON explore_index(entity_type, mbid)
`); err != nil {
return fmt.Errorf("migration 12: create mbid index: %w", err)
}
// FTS5 virtual table backed by the content table.
if _, err := db.ExecContext(ctx, `
CREATE VIRTUAL TABLE IF NOT EXISTS explore_index_fts USING fts5(
title, artist_name,
content='explore_index',
content_rowid='id'
)
`); err != nil {
return fmt.Errorf("migration 12: create FTS5 table: %w", err)
}
// Triggers to keep FTS in sync.
if _, err := db.ExecContext(ctx, `
CREATE TRIGGER IF NOT EXISTS explore_index_ai AFTER INSERT ON explore_index BEGIN
INSERT INTO explore_index_fts(rowid, title, artist_name)
VALUES (new.id, new.title, new.artist_name);
END
`); err != nil {
return fmt.Errorf("migration 12: create insert trigger: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE TRIGGER IF NOT EXISTS explore_index_ad AFTER DELETE ON explore_index BEGIN
INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name)
VALUES ('delete', old.id, old.title, old.artist_name);
END
`); err != nil {
return fmt.Errorf("migration 12: create delete trigger: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE TRIGGER IF NOT EXISTS explore_index_au AFTER UPDATE ON explore_index BEGIN
INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name)
VALUES ('delete', old.id, old.title, old.artist_name);
INSERT INTO explore_index_fts(rowid, title, artist_name)
VALUES (new.id, new.title, new.artist_name);
END
`); err != nil {
return fmt.Errorf("migration 12: create update trigger: %w", err)
}
// Metadata table for build tracking.
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS explore_index_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`); err != nil {
return fmt.Errorf("migration 12: create meta table: %w", err)
}
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 12",
); err != nil {
return fmt.Errorf("could not set user_version to 12: %w", err)
}
logger.Info("migration 12 complete")
return nil
}
// migration13MBIDColumns adds MusicBrainz ID columns to artists,
// release_groups, and recordings for linking local library entities
// to MusicBrainz/ListenBrainz explore data.
func migration13MBIDColumns(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 13: MusicBrainz ID columns")
alterStmts := []struct {
table string
column string
}{
{"artists", "mbid"},
{"release_groups", "mbid"},
{"recordings", "mbid"},
}
for _, s := range alterStmts {
stmt := fmt.Sprintf(
"ALTER TABLE %s ADD COLUMN %s TEXT", s.table, s.column,
)
if _, err := db.ExecContext(ctx, stmt); err != nil {
// Column may already exist from a partial migration.
if !strings.Contains(err.Error(), "duplicate column") {
return fmt.Errorf("migration 13: alter %s: %w", s.table, err)
}
}
}
// Partial indexes for MBID lookups (only index non-NULL rows).
indexes := []string{
"CREATE INDEX IF NOT EXISTS idx_artists_mbid ON artists(mbid) WHERE mbid IS NOT NULL",
"CREATE INDEX IF NOT EXISTS idx_release_groups_mbid ON release_groups(mbid) WHERE mbid IS NOT NULL",
"CREATE INDEX IF NOT EXISTS idx_recordings_mbid ON recordings(mbid) WHERE mbid IS NOT NULL",
}
for _, stmt := range indexes {
if _, err := db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("migration 13: create index: %w", err)
}
}
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 13",
); err != nil {
return fmt.Errorf("could not set user_version to 13: %w", err)
}
logger.Info("migration 13 complete")
return nil
}
// migration14ExploreAliases adds an aliases column to explore_index
// and rebuilds the FTS5 virtual table with three searchable columns
// (title, artist_name, aliases) for alias-aware search.
func migration14ExploreAliases(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 14: explore index aliases + FTS5 rebuild")
// Add aliases column to content table.
if _, err := db.ExecContext(ctx,
"ALTER TABLE explore_index ADD COLUMN aliases TEXT DEFAULT ''",
); err != nil {
if !strings.Contains(err.Error(), "duplicate column") {
return fmt.Errorf("migration 14: alter explore_index: %w", err)
}
}
// Drop old triggers.
for _, name := range []string{
"explore_index_ai", "explore_index_ad", "explore_index_au",
} {
if _, err := db.ExecContext(ctx,
"DROP TRIGGER IF EXISTS "+name,
); err != nil {
return fmt.Errorf("migration 14: drop trigger %s: %w", name, err)
}
}
// Drop and recreate FTS5 with 3 columns.
if _, err := db.ExecContext(ctx,
"DROP TABLE IF EXISTS explore_index_fts",
); err != nil {
return fmt.Errorf("migration 14: drop FTS5: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE VIRTUAL TABLE explore_index_fts USING fts5(
title, artist_name, aliases,
content='explore_index',
content_rowid='id'
)
`); err != nil {
return fmt.Errorf("migration 14: create FTS5: %w", err)
}
// Recreate triggers with 3 columns.
if _, err := db.ExecContext(ctx, `
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
`); err != nil {
return fmt.Errorf("migration 14: create insert trigger: %w", err)
}
if _, err := db.ExecContext(ctx, `
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
`); err != nil {
return fmt.Errorf("migration 14: create delete trigger: %w", err)
}
if _, err := db.ExecContext(ctx, `
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
`); err != nil {
return fmt.Errorf("migration 14: create update trigger: %w", err)
}
// Rebuild FTS5 index from existing content table rows.
if _, err := db.ExecContext(ctx,
"INSERT INTO explore_index_fts(explore_index_fts) VALUES ('rebuild')",
); err != nil {
return fmt.Errorf("migration 14: rebuild FTS5: %w", err)
}
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 14",
); err != nil {
return fmt.Errorf("could not set user_version to 14: %w", err)
}
// Clear the index build timestamp so the next build populates aliases.
_, _ = db.ExecContext(ctx,
"DELETE FROM explore_index_meta WHERE key IN ('tier1_built', 'discog_built')",
)
logger.Info("migration 14 complete")
return nil
}
// migration15PersonalizationColumns adds in_library and is_similar
// columns to explore_index for personalized search ranking.
func migration15PersonalizationColumns(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 15: personalization columns")
for _, col := range []string{"in_library", "is_similar"} {
stmt := fmt.Sprintf(
"ALTER TABLE explore_index ADD COLUMN %s INTEGER NOT NULL DEFAULT 0", col,
)
if _, err := db.ExecContext(ctx, stmt); err != nil {
if !strings.Contains(err.Error(), "duplicate column") {
return fmt.Errorf("migration 15: alter explore_index: %w", err)
}
}
}
// Backfill in_library for artists already in the library.
if _, err := db.ExecContext(ctx, `
UPDATE explore_index SET in_library = 1
WHERE entity_type = 'artist'
AND mbid IN (SELECT mbid FROM artists WHERE mbid IS NOT NULL AND mbid != '')
`); err != nil {
logger.Warn("migration 15: backfill in_library artists", "error", err)
}
// Backfill in_library for release groups already in the library.
if _, err := db.ExecContext(ctx, `
UPDATE explore_index SET in_library = 1
WHERE entity_type = 'release_group'
AND mbid IN (SELECT mbid FROM release_groups WHERE mbid IS NOT NULL AND mbid != '')
`); err != nil {
logger.Warn("migration 15: backfill in_library release_groups", "error", err)
}
// Clear discog_built so the next index build populates these flags.
_, _ = db.ExecContext(ctx,
"DELETE FROM explore_index_meta WHERE key = 'discog_built'",
)
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 15",
); err != nil {
return fmt.Errorf("could not set user_version to 15: %w", err)
}
logger.Info("migration 15 complete")
return nil
}
// migration16ArtistImages creates the artist_images table for
// storing multiple artist photos from multiple sources, with
// thumbnail generation for the primary image.
func migration16ArtistImages(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 16: artist_images table")
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS artist_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
artist_mbid TEXT NOT NULL,
source TEXT NOT NULL,
source_url TEXT NOT NULL,
file_path TEXT NOT NULL,
is_primary INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
width INTEGER,
height INTEGER,
file_size INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`); err != nil {
return fmt.Errorf("migration 16: create artist_images: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_artist_images_mbid
ON artist_images(artist_mbid)
`); err != nil {
return fmt.Errorf("migration 16: create mbid index: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_images_source
ON artist_images(artist_mbid, source, source_url)
`); err != nil {
return fmt.Errorf("migration 16: create source index: %w", err)
}
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 16",
); err != nil {
return fmt.Errorf("could not set user_version to 16: %w", err)
}
logger.Info("migration 16 complete")
return nil
}
func migration17SimilarArtistMap(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info("applying migration 17: similar_artist_map table")
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS similar_artist_map (
source_artist_mbid TEXT NOT NULL,
similar_artist_mbid TEXT NOT NULL,
similar_artist_name TEXT NOT NULL,
score INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (source_artist_mbid, similar_artist_mbid)
)
`); err != nil {
return fmt.Errorf("migration 17: create similar_artist_map: %w", err)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_similar_artist_map_source
ON similar_artist_map(source_artist_mbid)
`); err != nil {
return fmt.Errorf("migration 17: create source index: %w", err)
}
if _, err := db.ExecContext(ctx,
"PRAGMA user_version = 17",
); err != nil {
return fmt.Errorf("could not set user_version to 17: %w", err)
}
logger.Info("migration 17 complete")
return nil
}
// readLibraryDirFromTOML reads the TOML config file and returns
// the Library.DirectoryPath value, or "" if not configured.
func readLibraryDirFromTOML(logger *slog.Logger) string {
configDir, err := system.GetUserConfigDirPath()
if err != nil {
logger.Debug(
"could not get config dir for TOML read",
"err", err,
)
return ""
}
configPath := path.Join(configDir, "config.toml")
data, err := os.ReadFile(configPath)
if err != nil {
logger.Debug(
"could not read config.toml",
"path", configPath,
"err", err,
)
return ""
}
// Minimal struct to extract only the Library.DirectoryPath field.
var cfg struct {
Library struct {
DirectoryPath string `toml:"DirectoryPath"`
} `toml:"Library"`
}
if _, err := toml.Decode(string(data), &cfg); err != nil {
logger.Debug(
"could not parse config.toml",
"path", configPath,
"err", err,
)
return ""
}
return cfg.Library.DirectoryPath
}
// removeLibraryDirFromTOML reads the TOML config, removes the
// Library.DirectoryPath field, and writes the config back. This
// ensures the libraries table is the sole source of truth after
// migration.
func removeLibraryDirFromTOML(logger *slog.Logger) {
configDir, err := system.GetUserConfigDirPath()
if err != nil {
logger.Warn(
"could not get config dir for TOML cleanup",
"err", err,
)
return
}
configPath := path.Join(configDir, "config.toml")
data, err := os.ReadFile(configPath)
if err != nil {
logger.Warn(
"could not read config.toml for cleanup",
"path", configPath,
"err", err,
)
return
}
// Parse the full config as a generic map to preserve all fields.
var cfg map[string]any
if _, err := toml.Decode(string(data), &cfg); err != nil {
logger.Warn(
"could not parse config.toml for cleanup",
"err", err,
)
return
}
// Remove DirectoryPath from [Library] section.
if lib, ok := cfg["Library"].(map[string]any); ok {
delete(lib, "DirectoryPath")
// If Library section is now empty, remove it entirely.
if len(lib) == 0 {
delete(cfg, "Library")
}
}
// Write updated config back.
out, err := toml.Marshal(cfg)
if err != nil {
logger.Warn(
"could not marshal updated config.toml",
"err", err,
)
return
}
if err := os.WriteFile(configPath, out, 0o644); err != nil {
logger.Warn(
"could not write updated config.toml",
"path", configPath,
"err", err,
)
return
}
logger.Info(
"removed Library.DirectoryPath from config.toml",
"path", configPath,
)
}